Skip to content
In development. Nothing in Store 6 is published yet.

Draining, offline, and restart

What a drain is

drain(key) runs one idempotent foreground pass for the key's terminal canonical identity. It captures the unprojected confirmed base, then pushes the pending prefix once in durable client-sequence FIFO order. The pass does not fetch, retry a failed push, or wait for backoff.

drain() is the global form. It takes a snapshot of the durable identities in the journal, reconstructs each key through the resolver, and validates the returned namespace and canonical id. If an identity cannot be resolved, Store records an IDENTITY failure. For an affected pre-ack head, it durably parks that head and continues with other identities. Within an effective identity, processing is deterministic by durable client sequence. There is no promised order across keys.

Namespace ownership

Once transport becomes possible or its result is uncertain, that execution owns its (clientId, namespace) lane until it parks or retires. A keyed drain for another key in the same namespace returns without transport. Work in another namespace remains eligible to progress.

After cancellation, remote acceptance may be unknown once a generation is INFLIGHT. The next explicit drain or restart must resend that exact immutable generation before a later key in the same namespace can reach transport.

Namespace ownership prevents leapfroggingAn uncertain head generation in Namespace A blocks a later key, while an eligible head in Namespace B can push and retire.NAMESPACE ANAMESPACE BHead generationacceptance uncertainLater keyblocked behind ownerEligible headPush and retireLane ownerBlocked workEligible work
An uncertain head generation in Namespace A blocks a later key, while an eligible head in Namespace B can push and retire.Open full size

The ordering unit is the durable client sequence within one effective identity. Namespace ownership prevents causal leapfrogging after transport begins; it does not create a global order between unrelated keys.

Durable identity and the resolver

A key's durable identity is exactly (namespace.value, canonicalId()). Hashes, object identity, and the key's Kotlin class are not durable identity. See Designing keys for the corresponding StoreKey contract.

MutationKeyResolver<K> is a required mutationStore input because a global drain after restart must reconstruct a process-local key from that pair. Its resolve function is suspending, may do I/O, and is never invoked while the journal transaction is held. CancellationException is always re-thrown.

For an identity-reconstructible key, the resolver can be one expression. For an app-owned catalog, map lookup naturally returns null when the pair is unknown:

kotlin
val reconstructible =
    MutationKeyResolver<UserKey> { identity -> UserKey(identity.canonicalId) }

val lookupBacked =
    MutationKeyResolver<UserKey> { identity ->
        knownKeys[identity.namespace to identity.canonicalId]
    }

Store compares both resolved.namespace.value and resolved.canonicalId() verbatim with the requested pair before transport. A resolver that returns null, throws a non-cancellation exception, or returns either component incorrectly produces an IDENTITY failure. Do not normalize, hash, or infer either component inside the resolver unless that transformation is already the key's exact durable identity rule.

Restart and hydration

Install durable journal storage before treating the queue as restart-safe. Leaving journalStorage unset creates a mutations-owned in-memory journal, so pending work disappears with the process. A replacement store must open the same durable storage or database. See Journal storage for the storage contract.

On first use, the replacement store reads one coherent durable snapshot and rebuilds its process caches. Decode work happens after the storage transaction returns, and hydration itself emits no overlay or alias revision. Pending work then remains available to an explicit drain.

A restart may encounter a generation whose push was accepted remotely but whose local ack-receipt transaction did not commit. Its durable phase remains INFLIGHT, so replaying the same immutable generation and idempotency key is deliberate. Once the receipt is durable, restart resumes adoption, effect application, or retirement finalization without re-pushing. That is why the remote-acceptance crash window requires an idempotent endpoint.

Backoff is internal

Mutation pacing uses an internal full-jitter exponential eligibility window with a 1,000 ms base and a 300,000 ms cap. The jitter is drawn again for each global pass rather than persisted. There is no public policy door. The engine does not schedule a wake-up by itself: a later app-owned trigger still has to call a drain.

A keyed drain(key) explicitly bypasses the backoff wait; each selected head in its captured prefix gets at most one transport attempt. The global drain() sweep respects the internal eligibility window and continues across other eligible identities. Neither form loops until a failed push succeeds.

This differs from the core read engine, which gives a fetcher zero automatic retries and zero backoff. A failed read is retried only by new demand or by policy inside your fetcher. A mutation push is retried only when your code triggers another drain; internal pacing can delay its eligibility in a global sweep.

Observing progress

Treat pending(key), pendingWrites(), and deadLetters() as durable truth. The events flow is an advisory, in-process lifecycle feed with no replay and may drop old events under pressure. It is not a settlement or retry protocol. See Inspecting mutations for the state and recovery surfaces.


Source recorded 2026-08-12 ·main@539614c0· pre-6.0.0-alpha01