eve
Version:
Filesystem-first framework for durable backend AI agents that run anywhere.
106 lines • 6.05 kB
TypeScript
/**
* Wait-continuation dispatch: delay + idempotency-key selection.
*
* When V2 suspension processing observes a pending wait, it enqueues a
* delayed "continuation" message that fires once the wait elapses and
* drives the next replay (which completes the wait via the "complete
* elapsed waits" pass). This module decides the message's `delaySeconds`
* and `idempotencyKey`.
*
* The continuation is keyed on the wait's correlationId: while a wait is
* pending, every replay pass over the run re-observes it (e.g., once per
* step completion in `Promise.all([steps..., sleep()])`), and without
* dedupe each pass would enqueue another delayed continuation: each one
* a spurious full replay when the wait elapses, and each a fresh message
* that resets the delivery-attempt runaway guard. A key is attached in
* all cases: some worlds (e.g. world-postgres) serialize key-less
* workflow messages per run, which would park the continuation behind
* the handler's own inline step execution and defeat the race semantics
* the continuation exists to provide.
*
* The bare correlationId cannot be the key in every case, though: world
* dedupe windows outlive the first delivery (VQS keeps idempotency
* records until message-retention TTL; world-postgres keeps a
* completed-keys cache), so once a key has been used, a later enqueue
* under the same key is silently dropped. Any situation where a
* continuation is delivered while its wait is still pending therefore
* needs a fresh key for the re-enqueue, or the wait's timer is lost and
* the run stalls until unrelated traffic happens to wake it. Two such
* situations exist, each with its own key variation:
*
* - Waits longer than the maximum queue delay are chained: the delay is
* clamped to `WAIT_CONTINUATION_MAX_DELAY_SECONDS` (23h: VQS messages
* have a 24h retention limit, and one hour of buffer matches
* world-vercel's own clamp for delayed re-enqueues), so the
* continuation intentionally fires early, re-observes the wait, and
* must enqueue the next hop. The key is suffixed with the hop index
* (`ceil(timeoutSeconds / maxDelay)`): stable for every re-observation
* within the same hop window (so passes dedupe), decremented at each
* hop delivery (so the chain always advances). Worlds without a delay
* limit (world-postgres, world-local) take the same ≤23h hops.
*
* - Near-elapsed waits (≤2s remaining) get a second-bucketed suffix. A
* continuation delivered marginally early (clock skew between the
* enqueuing and handling hosts; the ceil() on the delay can leave a ~0
* margin) re-observes the wait as pending with ~1s remaining and must
* be able to enqueue a fresh short-delay retry. The bucket suffix
* keeps that retry enqueueable (its ≥1s delay guarantees a later
* bucket) while still collapsing same-instant duplicates.
*
* Mid-range waits (more than the near-elapsed threshold, at most one
* hop) use the bare correlationId: every re-observation targets the same
* deadline, so deduping to the first message is semantically lossless.
*
* That last case used to be the one hole in the scheme, and it was not
* theoretical. Any delivery early enough to re-observe its own wait as
* pending burns the bare key on the way in: the re-enqueue is dropped by
* the dedupe window, nothing else is scheduled to wake the run, and no
* backstop exists for a wait the way inline step ownership provides one
* for a step. The run sleeps forever. Waits over the threshold have zero
* tolerance for it, which is why an infrastructure change in delivery
* timing was able to strand runs across every published SDK version at
* once, all of them keyed this way.
*
* So the key is no longer derived from the wait alone. A continuation
* carries the wait it was armed for and its attempt number
* ({@link WorkflowInvokePayload.waitContinuation}), and an invocation
* that recognizes itself as the continuation for a wait that is still
* pending arms the next one at `attempt + 1`. Attempts advance ONLY when
* an early delivery actually happens, so the normal path is untouched:
* attempt 0 keys exactly as before, and every re-observation within one
* attempt still collapses to a single message.
*/
/**
* Maximum `delaySeconds` for a single wait-continuation message. Waits
* longer than this are chained across multiple hops. 23 hours leaves a 1h
* buffer under Vercel Queues' default 24h message TTL and mirrors
* world-vercel's `MAX_DELAY_SECONDS`.
*/
export declare const WAIT_CONTINUATION_MAX_DELAY_SECONDS = 82800;
/**
* Waits with at most this many seconds remaining use a second-bucketed
* idempotency key so an early-delivered continuation can re-enqueue its
* short-delay retry. This is also the host clock-skew tolerance for
* mid-range waits keyed on the bare correlationId.
*/
export declare const NEAR_ELAPSED_WAIT_THRESHOLD_SECONDS = 2;
export interface WaitContinuationDispatch {
delaySeconds: number;
idempotencyKey: string;
}
/**
* Computes the queue delay and idempotency key for a wait-continuation
* message. `timeoutSeconds` is the time until the wait's `resumeAt`
* (floored at 1s by the suspension handler); `waitCorrelationId`
* identifies the wait so repeated suspension passes dedupe.
*
* `attempt` is the number of continuations already spent on this wait, taken
* from the incoming message when this invocation IS one of them (see
* {@link WorkflowInvokePayload.waitContinuation}). It only ever moves when a
* continuation arrived before its wait elapsed, which is exactly when the
* previous key is spent and re-using it would drop the message. Attempt 0
* keys identically to the scheme before attempts existed, so the ordinary
* path — arm once, deliver once, complete — is byte-for-byte unchanged.
*/
export declare function getWaitContinuationDispatch(timeoutSeconds: number, waitCorrelationId: string, now?: number, attempt?: number): WaitContinuationDispatch;
//# sourceMappingURL=wait-continuation.d.ts.map