Storage Contract Reference
The adapter-author lookup for the IJobStore operation surface, its invariants, capability flags, schema versioning, and wake-up hints.
The Storage Contract is the semantic specification every storage implementation must
guarantee. Its C# seam is the IJobStore interface. An adapter that persists BackWave state
to a real database implements this interface; so does the deterministic in-memory store used in
tests and development. Both are full implementations, and both are held to it by an executable
conformance suite. This page is the lookup for
authoring an adapter: the operations IJobStore exposes, the invariants each one must uphold,
the capability flags a host uses to detect optional behavior, the bounds a store enforces, and
the schema-versioning and wake-up surfaces that live alongside the interface.
Everything on this page is the public contract. If a guarantee is stated here, an adapter must provide it, and the conformance suite checks that it does.
The clock invariant#
One rule governs every other guarantee: an implementation must never read its own clock for a
semantic decision. Every method that makes a time-dependent choice takes the current instant
as an explicit now parameter, and the store's behavior is fully determined by its inputs.
A claim decides what is due from the Now on the request, a lease expires against the now
passed to the sweep, and retention compares against the terminalBefore the caller supplies.
Nothing in a store calls DateTimeOffset.UtcNow on its own behalf.
This is what makes a store simulable and deterministic. Given the same sequence of calls with
the same now values, every conforming store produces the same result, which is why the
in-memory store can drive tests under virtual time and a real adapter can be certified against
the same expectations.
Capability detection#
Optional behavior is discovered in exactly two shapes: one property flag on the interface, and
two optional side-interfaces detected with an is check. A host inspects these once and adapts
its behavior; nothing else about a store is conditional.
| Capability | Shape | How a host detects it |
|---|---|---|
| Transactional enqueue | bool SupportsTransactionalEnqueue { get; } | Read the property. |
| History policy | JobHistoryPolicy HistoryPolicy { get; } | Read the property (default provided). |
| Wake-up hints | IWakeUpHintSource side-interface | store is IWakeUpHintSource |
| Transient-fault classification | IStoreFaultClassifier side-interface | store is IStoreFaultClassifier |
Transactional enqueue#
SupportsTransactionalEnqueue reports whether EnqueueAsync and EnqueueWorkflowAsync can
enlist in a caller-supplied ADO.NET DbTransaction, so a job commits or rolls back atomically
with the caller's own writes. A store that returns false must reject any non-null
transaction passed to those methods loudly. It must never silently ignore a transaction it
cannot honor, because a caller that believes its enqueue is transactional and finds it is not
has lost the guarantee it was relying on.
History policy#
HistoryPolicy is the single source of truth for how much transition history a store records.
It carries a default interface implementation returning JobHistoryPolicy.TransitionsAndFailureDetail,
so a store that records everything need not override it. Because the effective policy is
readable, a monitor can tell a genuinely empty timeline apart from one that is empty only
because recording is turned off. The Job history policy section covers
the ladder in full.
Transient-fault classification#
A store may implement IStoreFaultClassifier to tell the worker which store faults are worth
degrading and retrying rather than treating as fatal.
public interface IStoreFaultClassifier
{
bool IsTransientFault(Exception exception);
}Return true only for a transient store fault the worker should degrade-and-retry on the next
tick. Return false for everything else, including invariant violations and unknown faults,
which leaves the host's default classification untouched. The method must be a pure,
side-effect-free inspection, and it must never mistake a permanent fault for a transient one:
a false positive turns a fatal error into an endless retry.
Most adapters do not need this. Networked providers already raise transient conditions with
DbException.IsTransient set to true, and the host honors that. Implement the classifier only
when your provider leaves IsTransient unset for a fault you know to be transient contention,
such as a residual busy or locked condition that survives a busy-timeout.
The operation surface#
Every operation returns ValueTask or ValueTask<T> and takes a trailing
CancellationToken cancellationToken = default, omitted from the tables below for brevity. The
now parameter, where present, is always the caller's clock. The operations divide into three
groups: write and lifecycle operations that change committed state, read operations that a
monitor uses to observe it, and the observer-delivery operations that feed transition-log
observers.
Write and lifecycle operations#
| Method | Returns | Semantics and invariants |
|---|---|---|
EnqueueAsync(NewJob job, DateTimeOffset now, DbTransaction? transaction = null) | EnqueueResult | Creates one job. Rejects a duplicate id and any bound violation through the return enum; it never truncates and never replaces. A non-null transaction makes the insert atomic with the caller's writes, so a rollback means the job never existed. A job with parents starts in AwaitingParent; an already-terminal parent resolves the gate at enqueue. |
EnqueueWorkflowAsync(WorkflowDefinition workflow, DateTimeOffset now, DbTransaction? transaction = null) | WorkflowEnqueueResult | Inserts the whole workflow, all members and the workflow record, all-or-nothing, with no orphan members. Members with gating parents start AwaitingParent. Enforces intra-workflow containment: every gating parent of a member must itself be a member of the same workflow, else ContainmentViolation. Transaction semantics match EnqueueAsync. |
ClaimAsync(ClaimRequest request) | IReadOnlyList<JobRecord> | Atomically leases up to MaxJobs currently-due jobs from the requested queues. Claims each job to at most one caller, and never returns a job whose due time is after request.Now. The claim increments each job's Attempt: the claim is the start of the attempt. A claimed job stays invisible until its lease lapses or it goes terminal. A paused queue yields nothing, and a queue at its concurrency limit yields nothing. Jobs come back in due-time order, tiebroken by enqueue order, and the caller's ordered candidate-queue list is honored without cross-queue re-sorting. |
ClaimBatchAsync(ClaimRequest request) | ClaimResult | Claims exactly as ClaimAsync does. It also reports NextDue, the earliest future instant at which an empty claim can return work through time alone. A default implementation delegates to ClaimAsync and reports NextDue as null, so an adapter that keeps the default holds the fixed-rate behavior. An adapter that overrides it must compute NextDue against a snapshot consistent with the claim it just performed. It must report a value at or before request.Now when work is due now but a concurrency limit or the batch cap withheld it. It must exclude a paused queue, because that work does not become claimable through time alone. NextDue is advisory: it schedules the next poll and never affects correctness. |
ReportOutcomeAsync(Guid jobId, string workerId, int attempt, JobOutcome outcome, DateTimeOffset now, string? failureDetail = null, JobTags? addedTags = null, ReadOnlyMemory<byte>? output = null) | OutcomeResult | Applies one attempt's outcome and appends the transition atomically. Fenced by the (workerId, attempt) pair against the live lease: it applies only if the caller still holds the live lease for exactly this attempt, otherwise it changes nothing and returns StaleLease. failureDetail is write-only diagnostics, recorded only on Failure and truncated to the store's cap, never rejected. addedTags are unioned onto the job's tags on apply and discarded if fenced out. output is an opaque blob persisted only on Success, written atomically with the success transition and retained independently of history policy; over-cap output is rejected loudly, never truncated. |
ReportOutcomesAsync(IReadOnlyList<OutcomeReport> batch, DateTimeOffset now) | IReadOnlyList<OutcomeReportResult> | Reports a batch of outcomes, one result per row in the same order. Each row is fenced independently by its own (WorkerId, Attempt), so rows may freely mix Applied and StaleLease, and per-row semantics match ReportOutcomeAsync. Carries a default implementation that loops ReportOutcomeAsync, so every store is correct without overriding; an adapter may override for a single round-trip as long as the per-row fence and semantics are preserved. An empty batch applies nothing and returns an empty list. |
HeartbeatAsync(string workerId, IReadOnlyList<Guid> jobIds, TimeSpan leaseDuration, DateTimeOffset now) | IReadOnlyList<HeartbeatResult> | Renews the leases the worker still holds and reports each job's cancellation-requested flag in one round-trip. For any job the worker no longer holds, whether lapsed, terminal, or never held, the result carries Renewed = false, telling the worker to stop applying effects. A renewed lease expires at now + leaseDuration. |
ExpireLeasesAsync(DateTimeOffset now, int maxJobs, IReadOnlyList<string> queues, RetryDisposition disposition) | int | Disposes up to maxJobs jobs whose lease expired at or before now in the given queues. The claim already counted the attempt, so each expired job is rescheduled at the backoff instant in disposition or dead-lettered once the attempt ceiling is reached. Disposes each expired lease exactly once under concurrent sweeps, and treats disposition as pure data, never as executable code. Scoping to the caller's served queues lets each worker group's retry policy govern regardless of which node runs the sweep. |
CancelJobAsync(Guid jobId, string actor, DateTimeOffset now) | CancelResult | A not-yet-running job returns CancelledImmediately. A leased job has its cancellation-requested flag set and returns CancellationRequested, the cooperative path. A terminal or absent job returns NotCancellable. Appends the operator audit record naming actor atomically with the effect. |
RequeueAsync(Guid jobId, string actor, DateTimeOffset now) | RequeueResult | A dead-lettered or quarantined job returns to Scheduled with Attempt reset to 0, due at now, and returns Requeued. Any other state returns NotRequeueable with no effect. Appends the audit record atomically. |
PauseQueueAsync(string queue, string actor, DateTimeOffset now) | ValueTask | Pauses a queue cluster-wide so it yields nothing to claim; already-leased jobs are untouched. The effect is idempotent, but an audit record is appended on every call. |
ResumeQueueAsync(string queue, string actor, DateTimeOffset now) | ValueTask | Clears the paused flag. Idempotent, with an audit record appended atomically on every call. |
SetConcurrencyLimitAsync(string queue, int? limit, string actor, DateTimeOffset now) | ValueTask | Sets or, with null, clears a queue's cluster-wide concurrency limit. Slot usage is the count of currently-leased jobs, so a slot frees by construction on a terminal state or a lease expiry; there is no separate in-use counter. The effect is idempotent, with an audit record appended on every call. |
TriggerScheduleNowAsync(string scheduleId, string actor, DateTimeOffset now) | TriggerScheduleResult | Mints exactly one instance due at now without advancing the schedule cursor or disturbing future ticks. An unknown schedule returns ScheduleNotFound. Appends the audit record atomically. |
UpsertScheduleAsync(ScheduleRecord schedule) | ValueTask | Creates or replaces a schedule by id. A new schedule's cursor starts at upsert time, minting forward with no backfill. Redefining an existing schedule preserves its cursor, so resolved ticks are never replayed or skipped. |
RemoveScheduleAsync(string scheduleId) | ValueTask | Stops future minting; already-minted jobs are untouched. An unknown id has no effect. |
MintDueAsync(IReadOnlyList<MintDecision> decisions) | int | Per schedule, atomically advances the cursor and inserts the minted jobs. Skips a decision whole when its ExpectedCursor no longer matches the schedule's current cursor, meaning another node already minted, so ticks are never minted twice across the cluster. |
PurgeTerminalAsync(TerminalStateClass stateClass, DateTimeOffset terminalBefore, int maxJobs) | int | Deletes at most maxJobs jobs in the terminal stateClass whose terminal instant, never their enqueue time, is at or before terminalBefore. The batch is bounded so one sweep cannot storm; a caller repeats until a pass purges nothing. A job's transition log, tags, and output are deleted with it. |
Read and monitor operations#
Read operations take no now; they return committed state only. Every one is a snapshot a
monitor or a dependent job reads without changing anything.
| Method | Returns | Notes |
|---|---|---|
GetJobAsync(Guid jobId) | JobRecord? | One job's committed snapshot, or null if absent. |
GetJobOutputAsync(Guid jobId) | ReadOnlyMemory<byte>? | Reads only the output column, so a large blob never rides a listing or a claim. null when no output is set or the job is absent. This is the read a dependent job resolves to pull a parent's result. |
GetJobHistoryAsync(Guid jobId) | IReadOnlyList<JobTransition> | The append-only history, oldest first. One entry is appended atomically per state change; the log is bounded per job by the transition cap, with the oldest aging out, and is deleted with the job. An unknown job returns an empty list. |
ListJobsAsync(JobQuery query) | IReadOnlyList<JobRecord> | A filtered listing, page size clamped to MaxMonitorPageSize. |
CountJobsAsync() | IReadOnlyList<QueueStateCount> | Queue depths: one count per (Queue, State) pair that has at least one job. |
FacetAsync(string key, JobQuery? baseQuery = null) | IReadOnlyList<TagFacet> | Groups jobs by one tag dimension. Each count is the number of distinct jobs carrying (key, value), never a tag-row count. The empty-string key "" facets labels. A baseQuery scopes the population first, using the same predicates as ListJobsAsync while ignoring pagination and sort. Order is count descending, then value ascending by ordinal comparison, so it is deterministic and identical across every adapter. |
ListQueueSettingsAsync() | IReadOnlyList<QueueSettings> | The read-side mirror of pause, resume, and limit writes. Only queues with settings on record appear. In-use slots are not reported here; derive them from the leased count in CountJobsAsync. |
GetDependencyEdgesAsync(Guid jobId) | DependencyEdges | The still-gating parents plus the waiting children. Each parent edge is deleted as that parent terminates, so this is the still-gating set, not the full parent history. |
ListSchedulesAsync() | IReadOnlyList<ScheduleSnapshot> | All schedules with their mint-relevant state. This is a hot path, so the payload blob is omitted and ScheduleRecord.Payload comes back empty; minting re-reads the row for the real payload. |
ListAuditRecordsAsync(string target) | IReadOnlyList<OperatorAuditRecord> | The append-only operator trail for a target, which is a job id, a queue name, or a schedule id, oldest first. Exactly one record per operator action. |
ListWorkflowsAsync() | IReadOnlyList<WorkflowSnapshot> | Every workflow, oldest first by creation, with derived status and member count. |
GetWorkflowAsync(Guid workflowId) | WorkflowGraph? | One workflow's full graph: members with their current state, the immutable structural edges, and derived status. null if absent. |
Observer-delivery operations#
The five observer-delivery methods are mandatory members of IJobStore, not an optional
side-interface. When HistoryPolicy is Off there is nothing to observe, so they no-op and
report caught-up rather than erroring. They back the Transition Observer
delivery pipeline, which delivers each transition-log row to a subscribed observer at least
once.
| Method | Returns | Notes |
|---|---|---|
ClaimObserverDeliveriesAsync(ObserverClaimRequest request) | ObserverClaim | Claims a bounded batch of one observer's undelivered transition-log rows under a lease, in append order. At most one node holds a given observer's claim lease at a time; the happy path delivers each row once, and a lapsed claim lets another node redeliver, giving at-least-once with no leader election. Increments each row's delivery attempt. Returns an unacquired empty claim when another node holds the lease or nothing is due. |
ReportObserverDeliveriesAsync(ObserverDeliveryReport report) | ValueTask | Fenced by the claim lease. Delivered and dead-lettered rows advance the durable cursor over the contiguous resolved prefix; a retry row holds the cursor, which is head-of-line per observer. The cursor advance is made durable. |
GetObserverCursorAsync(string observerId) | long | The durable delivered-through position, or -1 when nothing has been delivered. |
GetObserverLagAsync(ObserverLagRequest request) | ObserverLag | The subscription-aware pending count and oldest-pending age. A disabled history reports caught-up. |
ListObserverDeadLettersAsync(string observerId) | IReadOnlyList<ObserverDeadLetterRecord> | Metadata only, oldest first. |
Each delivery carries its own retry counter, distinct from the job's Attempt. The supporting
records are ObserverClaimRequest, ObserverClaimedDelivery, ObserverClaim (with
ObserverClaim.None(observerId)), ObserverDeliveryOutcome, ObserverDeliveryReport,
ObserverLagRequest, ObserverLag, and ObserverDeadLetterRecord. A delivery is dispositioned
as one of ObserverDeliveryDisposition.Delivered, Retry, or DeadLettered.
Result and value types#
Every write operation reports its outcome through a small result enum rather than an exception, so a caller branches on the return value. Bound violations, duplicates, and fenced-out attempts are all ordinary return values.
| Enum | Members |
|---|---|
EnqueueResult | Ok, Duplicate, PayloadTooLarge, WireNameTooLong, UnknownParent, TooManyParents |
WorkflowEnqueueResult | Ok, DuplicateWorkflow, WorkflowNotFound, DuplicateMember, ContainmentViolation, EmptyWorkflow, PayloadTooLarge, WireNameTooLong, TooManyParents |
OutcomeResult | Applied, StaleLease |
CancelResult | CancelledImmediately, CancellationRequested, NotCancellable |
RequeueResult | Requeued, NotRequeueable |
TriggerScheduleResult | Triggered, ScheduleNotFound |
TerminalStateClass | SucceededOrCancelled, DeadLetteredOrQuarantined |
OperatorAction | Cancel, Requeue, TriggerScheduleNow, PauseQueue, ResumeQueue, SetConcurrencyLimit |
Any WorkflowEnqueueResult other than Ok inserts nothing. TerminalStateClass names the two
retention classes, each purged on its own keep window. OperatorAction persists as its numeric
value, so an adapter must append new actions rather than renumber existing ones.
The outcome a worker reports is a closed hierarchy, JobOutcome, whose cases carry the data the
store persists:
| Case | Meaning |
|---|---|
Success | The attempt succeeded. Any output supplied is persisted with the success transition. |
Failure(DateTimeOffset? NextDueTime, string Error) | A present NextDueTime reschedules the retry at that instant; a null one means the attempt ceiling is exhausted and the job is dead-lettered. The retry-versus-dead-letter decision is made above the store and delivered as data. |
Cancelled(string Cause) | The attempt was cancelled, with a recorded cause. |
Unroutable(string Reason) | The job cannot be routed and is quarantined, which is distinct from dead-lettered. |
The request and record types the operations exchange are listed below. Together they are the complete data surface an adapter reads from and writes to.
| Type | Shape |
|---|---|
NewJob | NewJob(Guid JobId, string WireName, ReadOnlyMemory<byte> Payload, string Queue, DateTimeOffset DueTime) plus init properties Parents (default empty), Mode (DependencyMode, default OnSuccess), TraceContext (string?, stored and returned verbatim, never interpreted), and Tags (default Empty). The id is caller-supplied; a duplicate is rejected, never replaced. |
DependencyMode | OnSuccess releases the child only if every parent succeeded, cancelling it otherwise; OnAnyTerminal releases once every parent is terminal regardless of how. |
ClaimRequest | ClaimRequest(string WorkerId, IReadOnlyList<string> Queues, int MaxJobs, TimeSpan LeaseDuration, DateTimeOffset Now). |
ClaimResult | ClaimResult(IReadOnlyList<JobRecord> Jobs, DateTimeOffset? NextDue). Jobs is exactly what ClaimAsync returns. NextDue is null when it is unknown or no future work exists. It is a future instant when the next scheduled job is due then, and at or before Now when the store withheld due work. |
OutcomeReport | OutcomeReport(Guid JobId, string WorkerId, int Attempt, JobOutcome Outcome) plus init properties FailureDetail, AddedTags, and Output. |
OutcomeReportResult | OutcomeReportResult(Guid JobId, OutcomeResult Result). |
HeartbeatResult | HeartbeatResult(Guid JobId, bool Renewed, bool CancelRequested). |
JobQuery | A record with State?, Queue?, WireName?, ScheduleId?, TagPredicates (default empty, AND-ed), AfterSequence? (a pagination cursor over JobRecord.Sequence), SortDirection (JobSortDirection, default OldestFirst), and MaxResults (default int.MaxValue, clamped to MaxMonitorPageSize). Every null or empty field adds no constraint. OR is out of scope; run two queries. |
JobSortDirection | OldestFirst sorts ascending by Sequence and is the default; NewestFirst sorts descending. |
JobTagPredicate | Built with the factories HasLabel(value), HasKeyValue(key, value), and HasKey(key). All predicates on a query are AND-ed. |
QueueStateCount | QueueStateCount(string Queue, JobState State, int Count). |
TagFacet | TagFacet(string Value, int Count). |
QueueSettings | QueueSettings(string Queue, bool Paused, int? ConcurrencyLimit). |
DependencyEdges | DependencyEdges(IReadOnlyList<Guid> GatingParents, IReadOnlyList<Guid> Children). |
OperatorAuditRecord | OperatorAuditRecord(string Actor, OperatorAction Action, string Target, DateTimeOffset RecordedAt). |
JobTransition | JobTransition(long Ordinal, DateTimeOffset Timestamp, JobState State, int Attempt, string? FailureDetail). Ordinal is the 0-based per-job sequence number, preserved even when older entries age out; Timestamp is the store's now. |
Over-cap success output is the one condition reported as an exception rather than a result enum:
public sealed class JobOutputTooLargeException : Exception
{
public Guid JobId { get; }
public int ActualBytes { get; }
public int MaxOutputBytes { get; }
}The job record and its states#
GetJobAsync, ClaimAsync, and ListJobsAsync all return JobRecord, the reader's snapshot
of one job. Its required fields are JobId, WireName, Payload, Queue, State, and
DueTime. The remaining fields are TraceContext?, Attempt (a claim increments it),
LeaseOwner?, LeaseExpiry?, CancelRequested, TerminalAt?, TerminalCause?, ScheduleId?,
ParentsRemaining (a countdown to zero), Sequence (store-assigned, strictly increasing with
insertion order, used as the within-queue tiebreak and the paging cursor), Mode (default
OnSuccess), WorkflowId? (immutable, at most one, never read by the core), Tags (default
Empty), and Output? (present only when the job succeeded, fetched on demand rather than in
listings).
State is a JobState, one of seven values. The full state machine, its transitions, and the
recorded terminal causes live on the Job States and Transitions
page.
| Value | Terminal |
|---|---|
Scheduled | No |
AwaitingParent | No |
Leased | No |
Succeeded | Yes |
Cancelled | Yes |
DeadLettered | Yes |
Quarantined | Yes |
The JobStates.IsTerminal() extension returns true for the four terminal states. A terminal
job never transitions on its own; only an explicit operator action can move it.
Storage bounds#
A store enforces a small set of named size and batch limits, collected in the StoreBounds
record. Each limit has a fixed default and a fixed enforcement rule. The governing principle is
that with one exception, failure detail, every limit is enforced with a clear error rather than
silent truncation. StoreBounds.Default is the singleton of the defaults below.
| Property | Default | Enforcement |
|---|---|---|
MaxPayloadBytes | 65_536 | Over-limit enqueue rejected with EnqueueResult.PayloadTooLarge. |
MaxWireNameLength | 128 | Over-limit enqueue rejected with WireNameTooLong. |
MaxClaimBatch | 32 | A larger request is clamped down. |
MaxParentsPerJob | 16 | Over-limit enqueue rejected with TooManyParents. |
MaxMonitorPageSize | 200 | A larger page request is clamped down. |
MaxPurgeBatch | 500 | A larger purge is clamped down. |
MaxRecordedSkippedTicks | 32 | The oldest skips age out. |
MaxTransitionsPerJob | 64 | The oldest transition is dropped beyond the cap. |
MaxFailureDetailBytes | 8_192 | Truncated, never rejected. |
MaxOutputBytes | 65_536 | Rejected with JobOutputTooLargeException, never truncated. |
Output and failure detail are the two ends of the reject-versus-truncate rule. Output is the
loud reject: over-cap success output throws rather than storing a partial blob. Failure detail
is the sole truncation. To truncate it identically across every store, use the record's
ClampFailureDetail(string?), which clamps to MaxFailureDetailBytes UTF-8 bytes, backing off
continuation bytes so it never splits a code point and always round-trips as valid text. Null or
short input passes through unchanged. The Limits and Defaults
page presents these same bounds alongside the retry and lease defaults.
Job history policy#
JobHistoryPolicy is a ladder. Each rung records more than the one below it.
| Policy | Records |
|---|---|
Off | No transition rows. |
Transitions | Transition rows, without failure detail. |
TransitionsAndFailureDetail | Transition rows plus clamped failure detail. The default. |
The one invariant an adapter must hold is that the policy gates writes, never schema. The
transition table always exists, so changing the policy is a configuration change, never a
migration. An Off store still answers GetJobHistoryAsync with an empty list and never
errors.
An adapter resolves its effective policy through JobHistoryPolicyResolver.Resolve(configured),
which downgrades the top rung to Transitions when the environment kill-switch is set. The
switch is the variable BACKWAVE_DISABLE_FAILURE_DETAIL; it is truthy for 1, or for true,
yes, or on case-insensitively. It guards against failure-detail text carrying stack traces,
secrets, or PII into the host database. Resolving through this resolver is how a store honors the
kill-switch identically to every other.
Deterministic minted ids#
Every store must derive a minted job's id the same way, because that is what makes scheduled
minting exactly-once across a cluster. JobIds.ForMintedTick(string scheduleId, DateTimeOffset tick)
is a pure function of its two arguments: the same schedule id and tick always produce the same
Guid. Racing minters on different nodes compute the identical id and collide on the primary
key, so only one instance inserts. An adapter must use this function rather than generate a fresh
id, or the exactly-once guarantee is lost.
Guid id = JobIds.ForMintedTick(scheduleId, tick);Wake-Up Hints#
A store may implement IWakeUpHintSource to notify workers that a queue has new work sooner
than the next poll would find it. Implement it only if the store has a notification primitive to
build on; a store without one simply does not implement the interface, and latency degrades to
the configured poll interval.
public interface IWakeUpHintSource
{
Task<IAsyncDisposable> SubscribeAsync(
Action<string> onHint,
CancellationToken cancellationToken = default);
}onHint receives the queue name and may be called concurrently. Disposing the returned handle
tears down the subscription. The subscription is self-maintaining: on channel loss it keeps
reconnecting until disposed, and while it is down, latency degrades to the poll interval and
nothing else changes.
A hint carries no delivery guarantee whatsoever. Hints may be dropped, duplicated, delayed, or reordered. A hint only ever makes a worker poll a queue sooner, and no correctness decision anywhere may depend on one. Polling is the sole source of truth, and the system must behave identically, apart from latency, if every hint is dropped. A callback that never fires, or fires for a queue with no work, is always acceptable.
Schema versioning and migration#
Schema versioning lives in each database adapter, not in the core storage surface. An adapter ships an ordered set of schema scripts and a single integer that names the schema version its build requires. The version is a plain equality gate: a store verifies the database's version on first use and refuses to run on any mismatch, because version skew must never be allowed to corrupt job state.
| Concern | Behavior |
|---|---|
| Expected version | A per-adapter constant. Adapters do not share a version number; each advances its own as its scripts change. |
| Applying the schema | A migrate step applies every schema script in version order, creating or upgrading tables. It is idempotent, so it is safe on every deploy and a no-op against a current database. |
| Verifying the schema | A verify step reads the recorded version and refuses to start on a mismatch, reporting the database's version and the version the adapter requires. A missing schema is reported distinctly, asking the operator to apply the schema or opt in to auto-migration first. |
| Auto-migration | An opt-in store option, off by default, so production applies the scripts as a deliberate step. Either way, a version mismatch stops the worker before any work runs. |
The schema scripts are the versioned artifact: each is numbered, applied in order, and records the new version as its final step, so a script filename maps one-to-one to a schema version. Because the scripts are additive and idempotent, an in-place upgrade with no drain is the supported upgrade path, and a mixed-version fleet is supported across a single version of skew. That single-version-skew guarantee is a design contract of the additive scripts, not a runtime check; the only mechanism the adapter enforces at runtime is the exact-version gate that refuses any mismatch.
A store's options are the adapter's public configuration surface. They carry the connection
details, the Bounds (defaulting to StoreBounds.Default), the HistoryPolicy (defaulting to
TransitionsAndFailureDetail, controlling writes only and never the schema), the auto-migrate
flag (defaulting off), and the schema name.
The schema name#
An adapter can host its tables under a configurable schema name, defaulting to backwave. The
default is a pure passthrough, so the emitted SQL is byte-identical to the authored scripts at no
cost; a custom name substitutes on first sight of each distinct query. A schema name is validated
at construction against a strict identifier pattern, ^[A-Za-z_][A-Za-z0-9_]*$, at most 63 bytes,
unquoted, so an invalid name fails fast and never reaches the database. The wake-up hint channel
is namespaced off the schema name, so two BackWave deployments sharing one database never
cross-talk.
Certifying an adapter#
The Storage Contract has an executable form: a conformance suite that exercises every guarantee on this page against a real store. An adapter is certified by subclassing the suite in an xUnit test project and providing a fresh, empty store for each test.
public sealed class MyAdapterConformanceTests : ConformanceSuite
{
protected override async ValueTask<IJobStore> CreateStoreAsync(JobHistoryPolicy historyPolicy)
{
// Return a clean-slate store honoring the given history policy.
}
}CreateStoreAsync is the one required override. It must return a fresh, empty store honoring the
requested history policy, a clean slate per call. Every test is a public fact, so the runner
discovers the whole suite by subclassing. The reference in-memory store passes it in full.
Two optional overrides tune what the suite exercises. An adapter that supports transactional
enqueue overrides BeginTransaction(IJobStore store) to produce a transaction the suite can
enlist; the default throws. An adapter whose batch outcome report is wrapped in one transaction
sets BatchOutcomesAreAtomic to true to enable the whole-batch atomicity test. The suite also
offers opt-in failpoint and interleaving hooks for the crash-mid-write and read-then-write
anomaly tests; a store that cannot simulate a given fault leaves the hook returning null, and
those tests skip.
The named invariants the suite asserts are the guarantees stated throughout this page, among
them: each claimed job is leased to at most one caller; no AwaitingParent job survives its
parent set going terminal, with the child gate resolving atomically with the parent's terminal
transition; and a terminal state or a lease expiry frees a concurrency-limit slot, since slots
are the count of live leases by construction. The suite additionally proves due-time ordering
with an enqueue-order tiebreak, honoring the caller's ordered queue list without re-sorting,
disjoint claims across concurrent pumps of one group, outcome fencing on the wrong worker or
attempt or a lapsed lease, expiry counting the claim's attempt, exactly-once disposal under
concurrent sweeps, cursor-fenced exactly-once minting, retention on the terminal clock, and that
concurrent duplicate inserts converge idempotently rather than surfacing a raw duplicate-key
error.
Where to go next#
- Job States and Transitions: the full state machine
JobStatedrives, every transition, and the recorded terminal causes. - Limits and Defaults: the storage bounds alongside the retry and lease defaults, in one place.
- Observers API: the Transition Observer surface the observer-delivery operations feed.
- Queues: claim order, concurrency limits, and pausing from the consumer's point of view.
- Scheduling: cron schedules, cursors, and the minting the store persists.
- Execution Model: how a claimed job is leased, heartbeated, and reported.
Found a problem on this page? Report an issue