Limits & Defaults
The named storage bounds — payload size, wire-name length, parent count, output bytes, batch sizes, plus the retry and lease defaults — each with its default value and the reject-not-truncate rule.
Every job store enforces a small set of named size and batch limits so a single misbehaving
enqueue or an oversized result cannot corrupt the store or starve a Worker. These limits live
on StoreBounds, a record with one default value per limit, and a store configured without
explicit bounds uses StoreBounds.Default. The governing rule is that limits are enforced with
clear errors rather than silent truncation, with one deliberate exception for write-only
diagnostics. Two related defaults, the retry attempt ceiling and the Worker Lease duration, are
not storage bounds, but they are collected here so every default sits in one place.
The storage bounds#
StoreBounds names each limit, its default, and what happens when a request exceeds it. The
behavior column is the important part: some limits reject the offending write, some clamp an
over-large request down to the cap, and some age or truncate unbounded-growth data.
| Bound | Default | Unit | Over-limit behavior |
|---|---|---|---|
MaxPayloadBytes | 65,536 | bytes | Rejected. The enqueue fails; nothing is created. |
MaxWireNameLength | 128 | characters | Rejected. The enqueue fails; nothing is created. |
MaxParentsPerJob | 16 | parents | Rejected. The enqueue fails; nothing is created. |
MaxOutputBytes | 65,536 | bytes | Rejected. The outcome write fails loudly. |
MaxFailureDetailBytes | 8,192 | bytes | Truncated (the one exception), on a code-point boundary. |
MaxClaimBatch | 32 | jobs | Clamped down. A larger claim request is capped at this. |
MaxPurgeBatch | 500 | jobs | Clamped down. A larger purge request is capped at this. |
MaxMonitorPageSize | 200 | rows | Clamped down. A larger page request is capped at this. |
MaxRecordedSkippedTicks | 32 | ticks | Ages out. The oldest recorded skips drop as new ones arrive. |
MaxTransitionsPerJob | 64 | entries | Ages out. The oldest transition drops when the cap would be exceeded. |
The Wire Name limit is measured in characters, counted as UTF-16 code units, so a name built from characters outside the Basic Multilingual Plane reaches the cap in fewer visible characters than its length in code units suggests.
Reject, clamp, or truncate#
Which of the three behaviors applies to a limit follows from what the data is used for.
- Size caps on data that is later read back and deserialized are rejected. Payload, Wire Name, Job Output, and the parent set are all functional inputs. A clipped payload or a clipped output blob cannot be deserialized, so silently truncating it would hand a downstream reader a corrupted value. Rejecting the write loudly is safer than corrupting the reader, so an over-limit enqueue or outcome fails and the store is left untouched.
- Caps on batch and paging requests are clamped down silently. Asking to claim, purge, or
page more rows than the cap allows is not an error; the request is simply served up to the
cap. A claim for 100 jobs against a
MaxClaimBatchof 32 returns at most 32. - Caps on write-only diagnostics and unbounded-growth history are aged or truncated. Failure Detail is never a semantic input, so it is truncated rather than refused. Transition history and recorded skipped ticks are bounded by dropping the oldest entries.
Payload and Wire Name#
An enqueue is checked against three bounds before the job is created: the serialized payload
size, the Wire Name length, and the number of declared parents. If any check fails, no job is
created. The parent set is de-duplicated before its count is checked, so declaring the same
parent twice does not count twice against MaxParentsPerJob.
At the client surface, an over-limit payload surfaces as an ArgumentException naming the Wire
Name and the actual byte count, with the guidance to store a reference rather than the data
itself.
// If the serialized OrderCharged payload exceeds MaxPayloadBytes,
// EnqueueAsync throws ArgumentException rather than creating the job.
await client.EnqueueAsync(new OrderCharged(orderId), DateTimeOffset.UtcNow);The remedy for a payload that is too large is to store the bulk data somewhere addressable and enqueue only a reference to it, an id or a blob key, keeping the job's payload small.
Job Output#
A handler may emit one Job Output, an opaque blob persisted only when the job succeeds. Every
other outcome, including a graceful failure, writes no output. When output is present on a
successful outcome, it is checked against MaxOutputBytes; an over-limit blob is rejected and
the outcome write throws JobOutputTooLargeException. The check runs before the outcome is
committed, so an over-limit write leaves the store untouched.
JobOutputTooLargeException carries the job id, the actual byte count, and the configured
MaxOutputBytes:
| Member | Type | Meaning |
|---|---|---|
JobId | Guid | The job whose output was rejected. |
ActualBytes | int | The size of the output blob that was refused. |
MaxOutputBytes | int | The configured output cap it exceeded. |
Output is rejected rather than truncated for the same reason as payload: a dependent descendant
deserializes it, and a clipped serialized blob is undeserializable. The default,
65,536 bytes, matches the payload cap, the natural size class for one job's result, but the
two limits are independent and can be set separately. As with an oversized payload, the fix is
to persist the large result externally and emit a reference to it as the output.
Failure Detail#
Failure Detail is the write-only diagnostics captured on a failing attempt: the exception type,
message, and stack. The Core never reads it back, so it is the single exception to the
reject-not-truncate rule. When captured detail exceeds MaxFailureDetailBytes, it is truncated
to fit rather than refused. The truncation is byte-exact and never splits a UTF-8 code point, so
the stored detail always round-trips as valid text; a null or already-short detail passes
through untouched.
Batch and page sizes#
Three limits cap how much work a single request moves, and all three clamp rather than reject. A request larger than the cap is served up to the cap and no error is raised.
| Bound | Default | Applies to |
|---|---|---|
MaxClaimBatch | 32 | The most jobs a single claim returns. |
MaxPurgeBatch | 500 | The most jobs a single purge removes. |
MaxMonitorPageSize | 200 | The most rows a monitor listing returns per page. |
Configuring the bounds#
StoreBounds is a record with an init property per limit and a static Default accessor. A
store configured without explicit bounds uses StoreBounds.Default; to override a limit,
construct a StoreBounds and pass it to the store. Because it is a record, use a with
expression to change one bound and keep the rest at their defaults.
// Start from the defaults and raise only the payload and output caps.
var bounds = StoreBounds.Default with
{
MaxPayloadBytes = 131_072,
MaxOutputBytes = 131_072,
};
var store = new InMemoryJobStore(bounds);Attempts and retries#
The number of times a job is tried before it dead-letters is not a storage bound. It is a
retry-policy value: RetryPolicy.MaxAttempts, which defaults to 10. Attempts are 1-based — the
first attempt is attempt 1 — and a claim increments the attempt counter, so a Lease that expires
mid-execution counts as an attempt exactly like a thrown exception does. When a job has failed
its final allowed attempt, it dead-letters instead of retrying.
RetryPolicy also carries the backoff schedule that spaces out retries. The default backoff is
exponential, two raised to the attempt number, in seconds, capped at five minutes.
| Member | Type | Default |
|---|---|---|
MaxAttempts | int | 10 |
Backoff | Func<int, TimeSpan> | Exponential, 2^attempt seconds, capped at 300s. |
// Fewer attempts, keeping the default exponential backoff.
var policy = RetryPolicy.Default with { MaxAttempts = 5 };A Job type can override the group policy with the
[Retry] attribute. That override has two
bounds of its own. The source generator validates both at compile time.
| Bound | Value | Applies to |
|---|---|---|
| Attempt ceiling | 1 to 1000 | The first argument to [Retry]. |
| Backoff intervals | 1 to 20 values | The interval list, which never accepts a negative value. |
These two bounds reject rather than clamp: a value outside the range is a build error, not a silently corrected one.
The Execution Model page covers how attempts, retries, and dead-lettering fit into a job's lifecycle.
Lease duration#
A Lease is a Worker's time-bounded, heartbeat-renewed claim on a job. Its duration is not a storage bound and not a fixed constant; it is supplied by the Worker on each claim and each heartbeat, and the store extends the job's lease that far past the current time. The default Worker Lease is 60 seconds, renewed by heartbeat while the job runs. When a Lease lapses without a heartbeat, because the Worker crashed or stalled, the job becomes claimable again, which is the mechanism behind at-least-once delivery.
Because the lease duration travels with each claim rather than being stored, a longer-running class of work simply claims with a longer lease; there is no single global lease setting to tune. The Execution Model page covers Leasing, heartbeats, and reclaim in full.
Where to go next#
- Execution Model: attempts, retries, dead-lettering, Leasing, and heartbeats in the context of a running job.
- Jobs and Handlers: payloads, Wire Names, and Job Output as part of a job's definition.
- Dependencies: declaring parents and reading a parent's
output, where
MaxParentsPerJobapplies. - Queues: the default Queue name, pool size, and Concurrency Limits, the other side of a deployment's defaults.
Found a problem on this page? Report an issue