packages/durability/tests/topology-worker.ts packages/durability/src/topology.ts packages/durability/src/scheduler.ts packages/durability/src/outbox.ts packages/durability/src/topology-client.ts packages/durability/src/topology-snapshot.ts packages/durability/src/topology.ts Open source ↗ import { DurableObject, exports as workerExports } from 'cloudflare:workers' ;
import { DurabilityScheduler } from '../src' ;
import {
DurabilityTopology,
type TopologyStateUpdate,
} from '../src/experimental' ;
export type Limits = { max : number };
type Env = { TOPOLOGY : KVNamespace };
const namespaceFor = < T extends Rpc . DurableObjectBranded >( name : string ) =>
(workerExports as Record < string , unknown >)[name] as DurableObjectNamespace < T >;
export const topologyKey = ( group : string ) => `topology/${ group }` ;
export class TopologyCoordinator extends DurableObject < Env > {
private readonly scheduler = new DurabilityScheduler ({ context: this .ctx });
private readonly topology = new DurabilityTopology ({
scheduler: this .scheduler,
publish: {
write : ( snapshot , encoded ) =>
this .env. TOPOLOGY . put ( topologyKey (snapshot.group), encoded),
delete : ( group ) => this .env. TOPOLOGY . delete ( topologyKey (group)),
},
states: {
limits: {
deliver : ( member , update : TopologyStateUpdate < Limits >) =>
namespaceFor < TopologyShard >( 'TopologyShard' )
. getByName (member.id)
. applyLimits (update),
},
},
});
create ( group : string , shards : number , limits : Limits ) {
return this .topology. update (group, () => ({
members: Array. from ({ length: shards }, ( _ , n ) => ({
id: `${ group }-${ n }` ,
state: 'active' as const ,
})),
states: { limits },
}));
}
rotate ( group : string , shardId : string ) {
return this .topology. update (group, ({ members }) => ({
members: [
... members. map (( member ) =>
member.id === shardId
? { ... member, state: 'draining' as const }
: member
),
{ id: `${ group }-${ members . length }` , state: 'active' },
],
}));
}
setLimits ( group : string , limits : Limits ) {
return this .topology. update (group, () => ({ states: { limits } }));
}
remove ( group : string ) {
return this .topology. delete (group);
}
override alarm ( info ?: AlarmInvocationInfo ) {
return this .scheduler. alarm (info);
}
}
export class TopologyShard extends DurableObject < Env > {
applyLimits ( update : TopologyStateUpdate < Limits >) {
this .ctx.storage. transactionSync (() => {
const applied = this .ctx.storage.kv. get < number >( 'limits:version' ) ?? 0 ;
if (update.version <= applied) {
return ;
}
this .ctx.storage.kv. put ( 'limits' , update.value);
this .ctx.storage.kv. put ( 'limits:version' , update.version);
});
}
limits () {
return this .ctx.storage.kv. get < Limits >( 'limits' );
}
} import { z } from 'zod' ;
import { reportFailure, TopologyError } from './errors.js' ;
import type { LifecycleHook } from './lifecycle.js' ;
import {
durabilityTopologyMigrations,
migrateCapability,
} from './migrations.js' ;
import {
DurabilityOutbox,
discardWithin,
publishNow,
stageWithin,
type DurabilityOutboxEvent,
type OutboxEntry,
} from './outbox.js' ;
import { assertPositiveInteger } from './policy.js' ;
import {
DurabilityScheduler,
engineFor,
type Engine,
type SchedulerAttachment,
} from './scheduler.js' ;
import { canonical, deserialize, serialize } from './serialization.js' ;
import {
TopologyClient,
type TopologyClientConfig,
} from './topology-client.js' ;
import {
encodeSnapshot,
memberInputSchema,
membersSchema,
type TopologyMember,
type TopologyMemberInput,
type TopologySnapshot,
} from './topology-snapshot.js' ;
/** `write` channels reach active members; `read` channels reach draining members too. */
export type TopologyRole = 'write' | 'read' ;
/** Publication and sync never give up: a stale topology must still converge. */
export type TopologyRetryOptions = {
/** Maximum duration of one write or delivery. Defaults to 30 seconds. */
attemptTimeoutMs ?: number ;
retries ?: {
/** Delay in milliseconds after a failed attempt. Defaults to jittered exponential backoff capped at 5 minutes. */
delay ?: ( attempt : number ) => number ;
};
};
export type TopologyStateUpdate < Value > = {
group : string ;
channel : string ;
/** Increases on every update sent to this member. Apply only versions newer than the last one applied. */
version : number ;
topologyVersion : number ;
value : Value ;
};
export type TopologyPublisher < Meta > = TopologyRetryOptions & {
/** Must be idempotent. Store `encoded` as is: it is what the client reads. */
write ( snapshot : TopologySnapshot < Meta >, encoded : string ) : Promise < unknown >;
delete ( group : string , version : number ) : Promise < unknown >;
};
export type TopologyChannel < Value , Meta > = TopologyRetryOptions & {
/** Defaults to `write`. */
members ?: TopologyRole ;
deliver (
member : TopologyMember < Meta >,
update : TopologyStateUpdate < Value >
) : unknown ;
};
/**
* Publication and sync run on outbox channels: `durability.topology` keyed by
* group, and `durability.topology.<channel>` keyed by `[group, member]`.
*/
export type DurabilityTopologyEvent = DurabilityOutboxEvent ;
export type DurabilityTopologyConfig <
Meta ,
States extends Record < string , unknown >,
> = SchedulerAttachment &
TopologyRetryOptions & {
/** Without a publisher, membership is only visible inside this object. */
publish ?: TopologyPublisher < Meta >;
states ?: { [ Name in keyof States ] : TopologyChannel < States [ Name ], Meta > };
onLifecycleEvent ?: ( event : DurabilityTopologyEvent ) => void | Promise < void >;
};
export type TopologyCurrent < Meta , States > = {
members : readonly TopologyMember < Meta >[];
states : Partial < States >;
};
export type TopologyDraft < Meta , States > = {
/** Omit to keep the current members. */
members ?: readonly TopologyMemberInput < Meta >[];
/** Only the channels being set. */
states ?: Partial < States >;
};
type Publication < Meta > =
| { snapshot : TopologySnapshot < Meta > }
| { deleted : { group : string ; version : number } };
type Delivery < Meta > = {
group : string ;
member : TopologyMember < Meta >;
topologyVersion : number ;
value : unknown ;
};
type Channel < Meta > = {
outbox : DurabilityOutbox < Delivery < Meta >>;
role : TopologyRole ;
};
const publicationChannel = 'durability.topology' ;
const groupRowSchema = z. object ({
group_id: z. string (),
version: z. number (). int (). positive (),
members: z. string (),
deleted: z. union ([z. literal ( 0 ), z. literal ( 1 )]),
});
type GroupRow = z . infer < typeof groupRowSchema>;
const stateRowSchema = z. object ({
channel: z. string (),
value: z. string (),
});
class TopologyTimeoutError extends Error {
constructor ( label : string , timeoutMs : number ) {
super ( `Topology ${ label } timed out after ${ timeoutMs }ms` );
this .name = 'TopologyTimeoutError' ;
}
}
const withTimeout = async < T >(
work : Promise < T >,
timeoutMs : number ,
label : string
) : Promise < T > => {
let timer : ReturnType < typeof setTimeout> | undefined ;
try {
return await Promise . race ([
work,
new Promise < never >(( _resolve , reject ) => {
timer = setTimeout (
() => reject ( new TopologyTimeoutError (label, timeoutMs)),
timeoutMs
);
}),
]);
} finally {
clearTimeout (timer);
}
};
// setTimeout overflows past this, and the outbox enforces the same ceiling.
const maxTimeoutMs = 2 ** 31 - 1 ;
const timeoutOf = (
options : TopologyRetryOptions | undefined ,
fallback : TopologyRetryOptions
) : number => {
const timeoutMs =
options?.attemptTimeoutMs ?? fallback.attemptTimeoutMs ?? 30_000 ;
assertPositiveInteger ( 'attemptTimeoutMs' , timeoutMs);
if (timeoutMs > maxTimeoutMs) {
throw new RangeError ( `attemptTimeoutMs must be at most ${ maxTimeoutMs }` );
}
return timeoutMs;
};
/**
* Each write or delivery has its own deadline inside a batch, so the outbox's
* batch deadline only bounds the callback itself.
*/
const outboxOptions = (
timeoutMs : number ,
options : TopologyRetryOptions | undefined ,
fallback : TopologyRetryOptions
) => {
const delay = options?.retries?.delay ?? fallback.retries?.delay;
return {
attemptTimeoutMs: Math. min (timeoutMs * 2 , maxTimeoutMs),
... (delay === undefined ? {} : { retries: { delay } }),
};
};
/**
* How long after its timeout an abandoned publisher write is assumed able to
* land. KV has no conditional write, so some bound is required.
*/
const landingWindowMs = 5 * 60_000 ;
const forgetWrite = ( sql : SqlStorage , id : string ) : void => {
sql. exec ( 'DELETE FROM durability_topology_writes WHERE id = ?' , id);
};
/** Drops expired records and returns when the group's last outstanding write can no longer land. */
const outstandingWritesUntil = (
sql : SqlStorage ,
group : string ,
now : number
) : number | undefined => {
sql. exec (
'DELETE FROM durability_topology_writes WHERE group_id = ? AND land_by <= ?' ,
group,
now
);
const { until } = z. object ({ until: z. number (). int (). nullable () }). parse (
sql
. exec (
`SELECT MAX(land_by) AS until FROM durability_topology_writes
WHERE group_id = ?` ,
group
)
. toArray ()[ 0 ]
);
return until ?? undefined ;
};
const assertGroup = ( group : unknown ) : void => {
if ( typeof group !== 'string' || group. length === 0 ) {
throw new TopologyError ( 'Group must be a non-empty string' );
}
};
const deliveryKey = ( group : string , member : string ) : string =>
JSON . stringify ([group, member]);
const isThenable = ( value : unknown ) : boolean =>
value !== null &&
typeof value === 'object' &&
typeof Reflect. get (value, 'then' ) === 'function' ;
const readGroup = ( sql : SqlStorage , group : string ) : GroupRow | undefined =>
groupRowSchema. optional (). parse (
sql
. exec (
`SELECT group_id, version, members, deleted
FROM durability_topology_groups WHERE group_id = ?` ,
group
)
. toArray ()[ 0 ]
);
const readStates = ( sql : SqlStorage , group : string ) =>
stateRowSchema. array (). parse (
sql
. exec (
`SELECT channel, value FROM durability_topology_states
WHERE group_id = ?` ,
group
)
. toArray ()
);
const parseMembers = < Meta >( row : GroupRow ) : TopologyMember < Meta >[] =>
membersSchema. parse (
JSON . parse (row.members)
) as unknown as TopologyMember < Meta >[];
const inRole = ( member : TopologyMember < unknown >, role : TopologyRole ) : boolean =>
role === 'read' || member.state === 'active' ;
/**
* Experimental. Holds the membership of shard groups inside one coordinator
* Durable Object, publishes each group's snapshot to a read store, and drives
* every member to the latest value of each state channel. Only this instance
* can change or publish its groups; readers use { @link DurabilityTopology.client } .
*/
export class DurabilityTopology <
Meta = undefined ,
States extends Record < string , unknown > = Record < string , never >,
> {
readonly #engine : Engine ;
readonly #coordinator : string ;
readonly #publication : DurabilityOutbox < Publication < Meta >> | undefined ;
readonly #channels = new Map < string , Channel < Meta >>();
constructor ( config : DurabilityTopologyConfig < Meta , States >) {
// The outboxes must share this helper's engine, so a private scheduler is
// created explicitly instead of letting each attach its own.
const scheduler = config.scheduler ?? new DurabilityScheduler (config);
this .#engine = engineFor ({ scheduler });
const coordinator = this .#engine. objectId ();
if (coordinator === undefined ) {
throw new TopologyError (
'DurabilityTopology needs the Durable Object context, which carries its ID'
);
}
this .#coordinator = coordinator;
this .#engine. migrateSchema ( 'topology' , durabilityTopologyMigrations);
// The outbox only emits outbox events, which is all this hook accepts.
const onLifecycleEvent = config.onLifecycleEvent as
| LifecycleHook
| undefined ;
const lifecycle =
onLifecycleEvent === undefined ? {} : { onLifecycleEvent };
const publisher = config.publish;
if (publisher !== undefined ) {
if (
typeof publisher.write !== 'function' ||
typeof publisher.delete !== 'function'
) {
throw new TopologyError ( 'publish needs write and delete functions' );
}
const timeoutMs = timeoutOf (publisher, config);
this .#publication = new DurabilityOutbox < Publication < Meta >>({
scheduler,
channel: publicationChannel,
... outboxOptions (timeoutMs, publisher, config),
... lifecycle,
publish : ( entries ) =>
this . #publishSnapshots (publisher, entries, timeoutMs),
});
}
const channels : Record <
string ,
TopologyChannel < unknown , Meta >
> = config.states ?? {};
for ( const [ name , channel ] of Object. entries (channels)) {
if ( ! name || typeof channel.deliver !== 'function' ) {
throw new TopologyError ( `Invalid topology channel "${ name }"` );
}
const timeoutMs = timeoutOf (channel, config);
this .#channels. set (name, {
role: channel.members ?? 'write' ,
outbox: new DurabilityOutbox < Delivery < Meta >>({
scheduler,
channel: `${ publicationChannel }.${ name }` ,
... outboxOptions (timeoutMs, channel, config),
... lifecycle,
publish : ( entries ) =>
this . #deliver (name, channel, entries, timeoutMs),
}),
});
}
}
/** A read-only client for stateless Workers. It never contacts the coordinator. */
static client < Meta = undefined >(
config : TopologyClientConfig < Meta >
) : TopologyClient < Meta > {
return new TopologyClient (config);
}
/** Reverts only the topology tables; outbox channels migrateCapability with `DurabilityOutbox.migrate`. */
static migrate (
context : Pick < DurableObjectState , 'storage' >,
target ?: string | null
) {
return migrateCapability (
context.storage,
'topology' ,
durabilityTopologyMigrations,
target
);
}
alarm ( info ?: AlarmInvocationInfo ) : Promise < void > {
return this .#engine. alarm (info);
}
/**
* Applies one change in a single transaction. `next` runs inside it and must
* return synchronously, so nothing can interleave between reading `current`
* and writing the draft. After the commit, one publish is attempted:
* `published: false` means the snapshot is not yet final in the store:
* the write failed, or an older write may still land over it. The outbox
* keeps going until it is.
*/
async update (
group : string ,
next : (
current : TopologyCurrent < Meta , States >
) => TopologyDraft < Meta , States >
) : Promise <{ snapshot : TopologySnapshot < Meta >; published : boolean }> {
assertGroup (group);
const snapshot = await this .#engine. transaction (( sql ) => {
const row = readGroup (sql, group);
const live = row?.deleted === 0 ? row : undefined ;
const before = live ? parseMembers < Meta >(live) : [];
const stateRows = readStates (sql, group);
const states = Object. fromEntries (
stateRows. map (( state ) => [state.channel, deserialize (state.value)])
) as Partial < States >;
// A copy, so an updater that mutates `current` cannot make a change look like a no-op.
const draft : unknown = next ({ members: structuredClone (before), states });
if ( isThenable (draft)) {
throw new TopologyError ( 'update must return its draft synchronously' );
}
const { members , states : changes } = this . #parseDraft (draft);
if ( ! live && members === undefined ) {
throw new TopologyError (
`Group "${ group }" does not exist; set members to create it`
);
}
const now = Date. now ();
const after =
members === undefined ? before : this . #normalize (members, before, now);
const changed =
! live ||
canonical ( JSON . stringify (after)) !== canonical ( JSON . stringify (before));
const version = changed ? (row?.version ?? 0 ) + 1 : live.version;
const current : TopologySnapshot < Meta > = {
group,
version,
coordinator: this .#coordinator,
members: after,
};
if (changed) {
sql. exec (
`INSERT INTO durability_topology_groups
(group_id, version, members, deleted, updated_at)
VALUES (?, ?, ?, 0, ?)
ON CONFLICT(group_id) DO UPDATE SET
version = excluded.version,
members = excluded.members,
deleted = 0,
updated_at = excluded.updated_at` ,
group,
version,
JSON . stringify (after),
now
);
this . #stagePublication (sql, group, { snapshot: current });
}
const changedChannels = this . #writeStates (sql, group, changes, stateRows);
this . #stageDeliveries (
sql,
group,
version,
before,
after,
changedChannels
);
return current;
});
return { snapshot, published: await this . #publishNow (group) };
}
/**
* Deletes the group in one transaction: members and state values are dropped
* and pending deliveries discarded. Versions keep counting, so a recreated
* group is newer than anything readers cached. One publisher delete is
* attempted after the commit.
*/
async delete ( group : string ) : Promise <{ published : boolean }> {
assertGroup (group);
await this .#engine. transaction (( sql ) => {
const row = readGroup (sql, group);
if (row?.deleted !== 0 ) {
return ;
}
const version = row.version + 1 ;
sql. exec (
`UPDATE durability_topology_groups
SET version = ?, members = '[]', deleted = 1, updated_at = ?
WHERE group_id = ?` ,
version,
Date. now (),
group
);
sql. exec (
'DELETE FROM durability_topology_states WHERE group_id = ?' ,
group
);
for ( const member of parseMembers < Meta >(row)) {
for ( const channel of this .#channels. values ()) {
discardWithin (channel.outbox, sql, deliveryKey (group, member.id));
}
}
this . #stagePublication (sql, group, { deleted: { group, version } });
});
return { published: await this . #publishNow (group) };
}
/** Coordinator-local read. Readers elsewhere use the published copy. */
snapshot ( group : string ) : TopologySnapshot < Meta > | undefined {
assertGroup (group);
const row = readGroup ( this .#engine.sql, group);
if (row?.deleted !== 0 ) {
return undefined ;
}
return {
group,
version: row.version,
coordinator: this .#coordinator,
members: parseMembers < Meta >(row),
};
}
# publishNow ( group : string ) : Promise < boolean > {
return this .#publication
? publishNow ( this .#publication, group)
: Promise . resolve ( true );
}
# stagePublication (
sql : SqlStorage ,
group : string ,
publication : Publication < Meta >
) : void {
if ( this .#publication) {
stageWithin ( this .#publication, sql, group, publication);
}
}
/**
* Every write is recorded before it starts and forgotten once it settles.
* A record that outlives its write (a timeout, or eviction mid-write) means
* an older snapshot may still land, so a newer write is not acknowledged
* until that record's landing deadline passes and it has been written again.
* Records live in storage, so this survives eviction.
*/
async # publishSnapshots (
publisher : TopologyPublisher < Meta >,
entries : OutboxEntry < Publication < Meta >>[],
timeoutMs : number
) : Promise < void > {
const landBy = Date. now () + timeoutMs + landingWindowMs;
const writes = entries. map (( entry ) => ({
entry,
id: crypto. randomUUID (),
}));
await this .#engine. transaction (( sql ) => {
for ( const { entry , id } of writes) {
sql. exec (
`INSERT INTO durability_topology_writes (id, group_id, land_by)
VALUES (?, ?, ?)` ,
id,
entry.key,
landBy
);
}
});
await Promise . all (
writes. map ( async ({ entry , id }) => {
const publication = entry.value;
const write = Promise . resolve (). then (() =>
'deleted' in publication
? publisher. delete (
publication.deleted.group,
publication.deleted.version
)
: publisher. write (
publication.snapshot,
encodeSnapshot (publication.snapshot)
)
);
try {
await withTimeout (write, timeoutMs, 'publish' );
} catch (error) {
if (error instanceof TopologyTimeoutError ) {
this . #settleLate (entry.key, id, write);
} else {
// A rejected write settled before any newer write of this group started.
await this .#engine. transaction (( sql ) => forgetWrite (sql, id));
}
reportFailure (
'durability.topology.publish_failed' ,
{ group: entry.key, attempt: entry.attempt },
error
);
entry. retry ();
return ;
}
const outstandingUntil = await this .#engine. transaction (( sql ) => {
forgetWrite (sql, id);
return outstandingWritesUntil (sql, entry.key, Date. now ());
});
if (outstandingUntil !== undefined ) {
entry. retry (Math. min (outstandingUntil - Date. now (), maxTimeoutMs));
}
})
);
}
/**
* An abandoned write that settles while this isolate lives may have landed
* over a newer one: forget it and restage the latest snapshot together. If
* the isolate dies first, its record makes the next publication wait instead.
*/
# settleLate ( group : string , id : string , write : Promise < unknown >) : void {
const onError = ( error : unknown ) =>
reportFailure ( 'durability.topology.restage_failed' , { group }, error);
const settled = write
. then (
() => undefined ,
() => undefined
)
. then (() =>
this .#engine. transaction (( sql ) => {
forgetWrite (sql, id);
this . #stageLatest (sql, group);
})
)
. catch (onError);
this .#engine. waitUntil (settled, onError);
}
/** A fresh version fences any in-flight publication, so the latest snapshot is written again. */
# stageLatest ( sql : SqlStorage , group : string ) : void {
const row = readGroup (sql, group);
if ( ! row) {
return ;
}
this . #stagePublication (
sql,
group,
row.deleted === 1
? { deleted: { group, version: row.version } }
: {
snapshot: {
group,
version: row.version,
coordinator: this .#coordinator,
members: parseMembers < Meta >(row),
},
}
);
}
async # deliver (
name : string ,
channel : TopologyChannel < unknown , Meta >,
entries : OutboxEntry < Delivery < Meta >>[],
timeoutMs : number
) : Promise < void > {
await Promise . all (
entries. map ( async ( entry ) => {
const { group , member , topologyVersion , value } = entry.value;
const update : TopologyStateUpdate < unknown > = {
group,
channel: name,
version: entry.version,
topologyVersion,
value,
};
try {
// Members ignore versions they have passed, so an abandoned delivery landing late is harmless.
await withTimeout (
Promise . resolve (). then (() => channel. deliver (member, update)),
timeoutMs,
'delivery'
);
} catch (error) {
reportFailure (
'durability.topology.delivery_failed' ,
{ group, channel: name, member: member.id, attempt: entry.attempt },
error
);
entry. retry ();
}
})
);
}
# parseDraft ( draft : unknown ) : {
members : readonly TopologyMemberInput < Meta >[] | undefined ;
states : Record < string , unknown >;
} {
if (draft === null || typeof draft !== 'object' ) {
throw new TopologyError ( 'update must return a draft object' );
}
const members : unknown = Reflect. get (draft, 'members' );
const states : unknown = Reflect. get (draft, 'states' ) ?? {};
if (members !== undefined && ! Array. isArray (members)) {
throw new TopologyError ( 'Draft members must be an array' );
}
if (
states === null ||
typeof states !== 'object' ||
Array. isArray (states)
) {
throw new TopologyError ( 'Draft states must be an object' );
}
return {
members: members as readonly TopologyMemberInput < Meta >[] | undefined ,
states: states as Record < string , unknown >,
};
}
# normalize (
inputs : readonly TopologyMemberInput < Meta >[],
before : readonly TopologyMember < Meta >[],
now : number
) : TopologyMember < Meta >[] {
if (inputs. length === 0 ) {
throw new TopologyError (
'A group needs at least one member; use delete() to remove it'
);
}
const joinedAt = new Map (
before. map (( member ) => [member.id, member.joinedAt])
);
const seen = new Set < string >();
const members = inputs. map (( input ) => {
const parsed = memberInputSchema. safeParse (input);
if ( ! parsed.success) {
throw new TopologyError ( `Invalid member: ${ parsed . error . message }` );
}
const { id , state , locationHint , meta } = parsed.data;
if (seen. has (id)) {
throw new TopologyError ( `Duplicate member "${ id }"` );
}
seen. add (id);
return {
id,
state,
... (locationHint === undefined ? {} : { locationHint }),
... (meta === undefined ? {} : { meta }),
joinedAt: joinedAt. get (id) ?? now,
} as unknown as TopologyMember < Meta >;
});
return members. sort (( a , b ) => (a.id < b.id ? - 1 : 1 ));
}
/** Returns the channels whose value changed. Unchanged values are not redelivered. */
# writeStates (
sql : SqlStorage ,
group : string ,
changes : Record < string , unknown >,
current : z . infer < typeof stateRowSchema>[]
) : Set < string > {
const existing = new Map (current. map (( row ) => [row.channel, row.value]));
const changed = new Set < string >();
for ( const [ channel , value ] of Object. entries (changes)) {
if (value === undefined ) {
continue ;
}
if ( ! this .#channels. has (channel)) {
throw new TopologyError ( `Unknown topology channel "${ channel }"` );
}
const stored = serialize (value);
const previous = existing. get (channel);
if (previous !== undefined && canonical (previous) === canonical (stored)) {
continue ;
}
sql. exec (
`INSERT INTO durability_topology_states (group_id, channel, value)
VALUES (?, ?, ?)
ON CONFLICT(group_id, channel) DO UPDATE SET value = excluded.value` ,
group,
channel,
stored
);
changed. add (channel);
}
return changed;
}
/**
* Members that entered a channel's role receive its current value; a changed
* value goes to every member in the role. Members that left lose their
* pending delivery.
*/
# stageDeliveries (
sql : SqlStorage ,
group : string ,
topologyVersion : number ,
before : readonly TopologyMember < Meta >[],
after : readonly TopologyMember < Meta >[],
changed : ReadonlySet < string >
) : void {
const values = new Map (
readStates (sql, group). map (( row ) => [row.channel, row.value])
);
for ( const [ name , channel ] of this .#channels) {
const previous = new Set (
before
. filter (( member ) => inRole (member, channel.role))
. map (({ id }) => id)
);
const targets = after. filter (( member ) => inRole (member, channel.role));
const kept = new Set (targets. map (({ id }) => id));
for ( const id of previous) {
if ( ! kept. has (id)) {
discardWithin (channel.outbox, sql, deliveryKey (group, id));
}
}
const value = values. get (name);
if (value === undefined ) {
continue ;
}
for ( const member of targets) {
if ( ! changed. has (name) && previous. has (member.id)) {
continue ;
}
stageWithin (channel.outbox, sql, deliveryKey (group, member.id), {
group,
member,
topologyVersion,
value: deserialize (value),
});
}
}
}
} import { z } from 'zod' ;
import { trace, traceMethods, traceStorage } from './dev.js' ;
import {
DurableRetryPolicyError,
isErrorInstance,
isNonRetryable,
reportFailure,
serializeError,
type SerializedError,
} from './errors.js' ;
import {
createEmitter,
type DurabilityLifecycleEvent,
type Emit,
type LifecycleEntity,
} from './lifecycle.js' ;
import {
isSchemaCurrent,
migrateCapability,
type MigrationCapability,
} from './migrations.js' ;
import { assertPositiveInteger, type AttemptPolicy } from './policy.js' ;
import { deleteMemos, deleteStaleMemos } from './memoize.js' ;
import {
claimAttempt,
exhaustAttempts,
updateAttempt,
type DurableTable,
} from './storage.js' ;
import type { DurableMigrations } from '@durability/storage' ;
/** The Durable Object state durability needs: storage plus optional `waitUntil`. */
export type DurabilityContext = Pick < DurableObjectState , 'storage' > &
Partial < Pick < DurableObjectState , 'waitUntil' >>;
/** Options for the scheduler shared by every durability helper in one Durable Object. */
export type DurabilitySchedulerOptions = {
/** Maximum handlers running at once across eager operations and alarm work. Defaults to 10. */
alarmConcurrency ?: number ;
/** Physical-alarm delay from 1 to 15 seconds. Defaults to 15 seconds. */
alarmMinDelayMs ?: number ;
/** Time before a running alarm hands unfinished work to a new alarm. Defaults to 14 minutes. */
alarmHandoffMs ?: number ;
/**
* Experimental. Start due work from in-memory timers at its exact scheduled
* time, independent of `alarmMinDelayMs`, and let newly due work run without
* waiting behind long-running handlers. The physical alarm stays armed as the
* eviction fallback. Off by default.
*/
liveTimers ?: boolean ;
/**
* Called each time an alarm run fails and the scheduler re-arms its own
* fallback alarm instead of letting the platform retry, for example to send
* the error to an external service. Best effort: it runs after the fallback is
* armed, a returned promise is passed to `waitUntil`, and a throw or rejection
* is only logged, so it never changes scheduling.
*/
onAlarmRetry ?: ( info : AlarmRetryInfo ) => void | Promise < void >;
};
/** Details of a failed alarm run passed to `onAlarmRetry`. */
export type AlarmRetryInfo = {
error : SerializedError ;
/** Delay until the re-armed fallback alarm fires. */
retryInMs : number ;
/** The platform's retry count for this alarm invocation, when it provided one. */
retryCount ?: number ;
/** Whether the platform marked this invocation as a retry, when it provided one. */
isRetry ?: boolean ;
/** The owning Durable Object's ID, when the context carries one. */
objectId ?: string ;
};
/** Configuration for a scheduler shared by several helpers. */
export type DurabilitySchedulerConfig = DurabilitySchedulerOptions & {
context : DurabilityContext ;
};
/**
* How a helper attaches to a scheduler: an existing shared one, or a private
* one created from the Durable Object context with the given scheduler options.
*/
export type SchedulerAttachment =
| { scheduler : DurabilityScheduler ; context ?: never }
| ( DurabilitySchedulerConfig & { scheduler ?: never });
const minAlarmDelayMs = 1_000 ;
const maxAlarmDelayMs = 15_000 ;
// setTimeout treats delays past this as zero, so anything further out can only
// be served by the physical alarm fallback.
const maxTimerMs = 2 ** 31 - 1 ;
const pendingHeadRowSchema = z. object ({
pk: z. string (),
next_attempt_at: z. number (). int (). nonnegative (),
});
const countRowSchema = z. object ({ count: z. number (). int (). nonnegative () });
type PhysicalAlarm = Pick <
DurableObjectTransaction ,
'getAlarm' | 'setAlarm' | 'deleteAlarm'
>;
/** One record table attached to the scheduler: how to find and run its due work. */
export type Participant = {
table : DurableTable ;
runDue ( now : number , info : AlarmInvocationInfo | undefined ) : Promise < void >;
/** Earliest due time, for tables without `status` and `next_attempt_at` columns. */
nextPendingAt ? ( sql : SqlStorage ) : number | undefined ;
/**
* True while the participant runs its own due work from an alarm invocation
* it keeps open. The in-memory timer then ignores its pending time, which only
* arms the physical alarm as the eviction fallback.
*/
holding ? () : boolean ;
};
/**
* Called after a record may have settled. Successful completions pass the
* executed row because some tables delete it on success; other changes pass
* nothing and listeners re-read storage.
*/
export type SettlementListener = ( completed ?: ExecutionSpec [ 'row' ]) => void ;
export type ActiveExecution = {
generation : string ;
createdAt : number ;
settled : Promise < void >;
controller ?: AbortController ;
};
/** Everything that differs between running a durable operation and a named alarm attempt. */
export type ExecutionSpec = {
table : DurableTable ;
key : string ;
row : { generation_id : string ; created_at : number };
label : string ;
entity : LifecycleEntity ;
active : Map < string , ActiveExecution >;
policy : AttemptPolicy ;
emit : Emit ;
/** When true, the completed event carries the handler result; omitted by default to keep results out of telemetry. */
includeResult ?: boolean ;
exhaustedError : () => Error ;
timeoutError : () => Error ;
invoke : ( attempt : number , signal : AbortSignal ) => unknown ;
/**
* Persists a successful attempt and returns its settlement timestamp, or
* undefined when a newer generation won. May throw to turn the success into
* a failure.
*/
complete : ( result : unknown , attempt : number ) => number | undefined ;
/** Kind-specific terminal rule beyond non-retryable errors and exhausted attempts. */
isTerminal : ( error : unknown ) => boolean ;
};
type RetryDecision = {
error : unknown ;
terminal : boolean ;
timestamp : number ;
nextAttemptAt : number ;
};
const decideRetry = (
spec : ExecutionSpec ,
attempt : number ,
error : unknown
) : RetryDecision => {
const terminal =
isNonRetryable (error) ||
attempt >= spec.policy.maxAttempts ||
spec. isTerminal (error);
if (terminal) {
const timestamp = Date. now ();
return { error, terminal, timestamp, nextAttemptAt: timestamp };
}
try {
const rawDelay = spec.policy. delay (attempt);
if ( ! Number. isFinite (rawDelay) || rawDelay < 0 ) {
throw new DurableRetryPolicyError (spec.label);
}
const delay = Math. round (rawDelay);
const timestamp = Date. now ();
if (
! Number. isSafeInteger (delay) ||
! Number. isSafeInteger (timestamp + delay)
) {
throw new DurableRetryPolicyError (spec.label);
}
return {
error,
terminal: false ,
timestamp,
nextAttemptAt: timestamp + delay,
};
} catch (policyError) {
const timestamp = Date. now ();
return {
error: isErrorInstance (policyError, DurableRetryPolicyError)
? policyError
: new DurableRetryPolicyError (spec.label, policyError),
terminal: true ,
timestamp,
nextAttemptAt: timestamp,
};
}
};
/**
* Shared machinery behind { @link DurabilityScheduler } : transactions, the physical
* alarm, the concurrency pool, and the attempt lifecycle. Package-internal.
*/
export class Engine {
readonly sql : SqlStorage ;
private readonly context : DurabilityContext ;
private readonly storage : DurableObjectStorage ;
private readonly alarmConcurrency : number ;
private readonly alarmMinDelayMs : number ;
private readonly alarmHandoffMs : number ;
readonly liveTimers : boolean ;
private readonly onAlarmRetry : DurabilitySchedulerOptions [ 'onAlarmRetry' ];
private readonly participants : Participant [] = [];
private readonly migrated = new Set < MigrationCapability >();
private availablePermits : number ;
private readonly permitWaiters : Array <( release : () => void ) => void > = [];
private alarmRefresh : Promise < void > | undefined ;
private earlyAlarm :
| { timestamp : number ; timer : ReturnType < typeof setTimeout> }
| undefined ;
private readonly watchers = new Map <
string ,
Map < string , Set < SettlementListener >>
> ();
// Keys whose attempt is currently executing, per table. The in-memory timer
// skips these so a still-due active row cannot drive a zero-delay timer loop.
private readonly activeByTable = new Map < string , Set < string >>();
constructor ( context : DurabilityContext , options : DurabilitySchedulerOptions ) {
this .context = context;
this .storage = traceStorage (context.storage);
this .sql = this .storage.sql;
this .alarmConcurrency = options.alarmConcurrency ?? 10 ;
this .alarmMinDelayMs = options.alarmMinDelayMs ?? 15_000 ;
this .alarmHandoffMs = options.alarmHandoffMs ?? 14 * 60_000 ;
this .liveTimers = options.liveTimers ?? false ;
this .onAlarmRetry = options.onAlarmRetry;
if (
this .onAlarmRetry !== undefined &&
typeof this .onAlarmRetry !== 'function'
) {
throw new TypeError ( 'onAlarmRetry must be a function' );
}
assertPositiveInteger ( 'alarmConcurrency' , this .alarmConcurrency);
assertPositiveInteger ( 'alarmMinDelayMs' , this .alarmMinDelayMs);
if (
this .alarmMinDelayMs < minAlarmDelayMs ||
this .alarmMinDelayMs > maxAlarmDelayMs
) {
throw new RangeError (
`alarmMinDelayMs must be between ${ minAlarmDelayMs } and ${ maxAlarmDelayMs }`
);
}
assertPositiveInteger ( 'alarmHandoffMs' , this .alarmHandoffMs);
this .availablePermits = this .alarmConcurrency;
}
register ( participant : Participant ) : void {
const { name } = participant.table;
if ( this .participants. some (({ table }) => table.name === name)) {
throw new Error ( `A ${ name } helper is already attached to this scheduler` );
}
this .participants. push (participant);
}
waitUntil (
promise : Promise < unknown >,
onError : ( error : unknown ) => void
) : void {
if ( ! this .context.waitUntil) {
return ;
}
try {
this .context. waitUntil (promise);
} catch (error) {
onError (error);
}
}
/**
* The owning Durable Object's ID. The public context type omits it, but the
* `DurableObjectState` applications pass at runtime carries it.
*/
objectId () : string | undefined {
const id : unknown = Reflect. get ( this .context, 'id' );
return id === undefined || id === null ? undefined : String (id);
}
emitter <
Event extends { entityKind : string; type : string } =
DurabilityLifecycleEvent,
> (
hook : (( event : Event ) => void | Promise < void >) | undefined
) : ( event : Event ) => void {
return createEmitter < Event >(hook, ( promise ) =>
this .context. waitUntil ?.(promise)
);
}
/** Applies a helper's SQLite migrations. */
migrateSchema (
capability : MigrationCapability ,
migrations : DurableMigrations
) : void {
// Helpers of one kind on one scheduler share their tables; checking the
// schema again for each would only add statements to every construction.
if ( this .migrated. has (capability)) {
return ;
}
if ( ! isSchemaCurrent ( this .sql, capability, migrations)) {
migrateCapability ( this .storage, capability, migrations, undefined );
}
this .migrated. add (capability);
}
/** Runs synchronous writes atomically without touching the physical alarm. */
transactionSync < T >( write : ( sql : SqlStorage ) => T ) : T {
return this .storage. transactionSync (() => write ( this .sql));
}
/**
* Runs the writes and reconciles the physical alarm in one storage
* transaction, then publishes the in-memory timer only after commit.
*/
async transaction < T >( write : ( sql : SqlStorage ) => T ) : Promise < T > {
let nextPendingAt : number | undefined ;
const result = await this .storage. transaction ( async ( transaction ) => {
const value = write ( this .sql);
nextPendingAt = await this . reconcile (transaction);
return value;
});
this . scheduleEarlyAlarm (nextPendingAt);
return result;
}
private trackActive ( table : string , key : string ) : void {
let keys = this .activeByTable. get (table);
if ( ! keys) {
keys = new Set ();
this .activeByTable. set (table, keys);
}
keys. add (key);
}
private releaseActive ( table : string , key : string ) : void {
const keys = this .activeByTable. get (table);
if ( ! keys) {
return ;
}
keys. delete (key);
if (keys.size === 0 ) {
this .activeByTable. delete (table);
}
}
/**
* The earliest pending time for one participant: `physical` arms the physical
* alarm (eviction fallback) and includes active work; `timer` drives the
* in-memory timer and, under liveTimers, skips work that is already executing
* so a still-due active row cannot re-fire a zero-delay timer in a loop.
*/
private pendingTimes ( participant : Participant ) : {
physical ?: number | undefined ;
timer ?: number | undefined ;
} {
if (participant.nextPendingAt) {
const at = participant. nextPendingAt ( this .sql);
if (at === undefined ) {
return {};
}
return participant. holding ?.()
? { physical: at }
: { physical: at, timer: at };
}
const active = this .activeByTable. get (participant.table.name);
const limit = this .liveTimers ? (active?.size ?? 0 ) + 1 : 1 ;
const rows = pendingHeadRowSchema. array (). parse (
this .sql
. exec (
`SELECT ${ participant . table . key } AS pk, next_attempt_at
FROM ${ participant . table . name }
WHERE status = 'pending'
ORDER BY next_attempt_at ASC
LIMIT ?` ,
limit
)
. toArray ()
);
const physical = rows[ 0 ]?.next_attempt_at;
if ( ! this .liveTimers) {
if (physical === undefined ) {
return {};
}
return { physical, timer: physical };
}
const eligible = rows. find (( row ) => ! active?. has (row.pk));
return { physical, timer: eligible?.next_attempt_at };
}
/**
* Arms the physical alarm from the earliest pending time (the eviction
* fallback) and returns the earliest time the in-memory timer should fire.
*/
private async reconcile (
physicalAlarm : PhysicalAlarm
) : Promise < number | undefined > {
const physicalTimes : number [] = [];
const timerTimes : number [] = [];
for ( const participant of this .participants) {
const { physical , timer } = this . pendingTimes (participant);
if (physical !== undefined ) {
physicalTimes. push (physical);
}
if (timer !== undefined ) {
timerTimes. push (timer);
}
}
const physicalNextAt =
physicalTimes. length === 0 ? undefined : Math. min ( ... physicalTimes);
const currentAlarm = await physicalAlarm. getAlarm ();
if (physicalNextAt === undefined ) {
if (currentAlarm !== null ) {
await physicalAlarm. deleteAlarm ();
}
return undefined ;
}
const target = Math. max (physicalNextAt, Date. now () + this .alarmMinDelayMs);
// Preserve a fallback inside this range so frequent reconciliation cannot push it later.
if (
currentAlarm === null ||
currentAlarm < physicalNextAt ||
currentAlarm > target
) {
await physicalAlarm. setAlarm (target);
}
return timerTimes. length === 0 ? undefined : Math. min ( ... timerTimes);
}
private scheduleEarlyAlarm ( timestamp : number | undefined ) : void {
if ( this .earlyAlarm?.timestamp === timestamp) {
return ;
}
if ( this .earlyAlarm) {
clearTimeout ( this .earlyAlarm.timer);
this .earlyAlarm = undefined ;
}
if (timestamp === undefined ) {
return ;
}
const delay = timestamp - Date. now ();
// Without liveTimers the in-memory timer only covers the sub-min-delay
// window; the physical alarm handles everything beyond it. With liveTimers
// it fires at the exact due time no matter how far out, except past the
// timer's range where only the physical fallback can serve it.
if ( ! this .liveTimers && delay >= this .alarmMinDelayMs) {
return ;
}
if (delay > maxTimerMs) {
return ;
}
const timer = setTimeout (
() => {
if ( this .earlyAlarm?.timer !== timer) {
return ;
}
this .earlyAlarm = undefined ;
trace ( 'scheduler.early_alarm' , { scheduledTime: timestamp });
const onError = ( error : unknown ) =>
reportFailure (
'durability.background_execution.failed' ,
{ scheduledTime: timestamp },
error
);
const execution = this . alarm (). catch (onError);
this . waitUntil (execution, onError);
},
Math. max (delay, 0 )
);
this .earlyAlarm = { timestamp, timer };
}
/** Coalesces concurrent refresh requests into one reconciliation transaction. */
scheduleNextAlarm () : Promise < void > {
if ( this .alarmRefresh) {
return this .alarmRefresh;
}
this .alarmRefresh = this . transaction (() => undefined ). finally (() => {
this .alarmRefresh = undefined ;
});
return this .alarmRefresh;
}
/**
* Aborts matching active handlers, then deletes one kind's records created
* before the timestamp and reconciles the alarm in the same transaction.
*/
async purge (
table : DurableTable ,
before : number ,
active : Map < string , ActiveExecution >,
abortReason : string
) : Promise < number > {
if ( ! Number. isSafeInteger (before) || before < 0 ) {
throw new RangeError ( 'timestamp must be a non-negative safe integer' );
}
for ( const running of active. values ()) {
if (running.createdAt < before) {
running.controller?. abort ( new Error (abortReason));
}
}
const removed = await this . transaction (( sql ) => {
const { count } = countRowSchema. parse (
sql
. exec (
`SELECT COUNT(*) AS count FROM ${ table . name } WHERE created_at < ?` ,
before
)
. toArray ()[ 0 ]
);
sql. exec ( `DELETE FROM ${ table . name } WHERE created_at < ?` , before);
deleteStaleMemos (sql, table);
return count;
});
if (removed > 0 ) {
for ( const key of this .watchers. get (table.name)?. keys () ?? []) {
this . notify (table, key);
}
}
return removed;
}
/** In-memory only: listeners never survive eviction. */
watch (
table : DurableTable ,
key : string ,
listener : SettlementListener
) : () => void {
const byKey =
this .watchers. get (table.name) ??
new Map < string , Set < SettlementListener >>();
this .watchers. set (table.name, byKey);
const listeners = byKey. get (key) ?? new Set < SettlementListener >();
byKey. set (key, listeners);
listeners. add (listener);
return () => {
listeners. delete (listener);
if (listeners.size === 0 && byKey. get (key) === listeners) {
byKey. delete (key);
}
};
}
/** Call only after the change has committed. */
notify (
table : DurableTable ,
key : string ,
completed ?: ExecutionSpec [ 'row' ]
) : void {
for ( const listener of this .watchers. get (table.name)?. get (key) ?? []) {
listener (completed);
}
}
tryAcquirePermit () : (() => void ) | undefined {
if ( this .availablePermits === 0 ) {
return undefined ;
}
this .availablePermits -= 1 ;
return this . createRelease ();
}
acquirePermit () : Promise <() => void > {
const release = this . tryAcquirePermit ();
if (release) {
return Promise . resolve (release);
}
return new Promise (( resolve ) => {
this .permitWaiters. push (resolve);
});
}
private createRelease () : () => void {
let released = false ;
return () => {
if (released) {
return ;
}
released = true ;
const waiter = this .permitWaiters. shift ();
if (waiter) {
waiter ( this . createRelease ());
} else {
this .availablePermits += 1 ;
}
};
}
async runConcurrent < Item >(
items : Item [],
worker : ( item : Item ) => Promise < void >
) : Promise < void > {
let nextIndex = 0 ;
await Promise . all (
Array. from (
{ length: Math. min ( this .alarmConcurrency, items. length ) },
async () => {
while (nextIndex < items. length ) {
const item = items[nextIndex ++ ];
if (item !== undefined ) {
// eslint-disable-next-line no-await-in-loop
await worker (item);
}
}
}
)
);
}
/**
* Runs due work and re-arms the physical alarm. A failure that would
* otherwise propagate to the platform is turned into a retry of our own,
* because the platform drops an alarm after a few failed deliveries.
*/
async alarm ( info ?: AlarmInvocationInfo ) : Promise < void > {
try {
await this . runAlarm (info);
} catch (error) {
// Throwing lets the platform retry, but it gives up after a few failures
// and drops the alarm, stranding pending work. Keeping our own alarm
// armed turns the failure into a retry we control instead.
if ( ! ( await this . armFallbackAlarm ())) {
throw error;
}
reportFailure (
'durability.alarm.failed' ,
{ retryInMs: this .alarmMinDelayMs },
error
);
this . notifyAlarmRetry (error, info);
}
}
private notifyAlarmRetry (
failure : unknown ,
invocation : AlarmInvocationInfo | undefined
) : void {
const hook = this .onAlarmRetry;
if ( ! hook) {
return ;
}
const objectId = this . objectId ();
const info : AlarmRetryInfo = {
error: serializeError (failure),
retryInMs: this .alarmMinDelayMs,
... (invocation === undefined
? {}
: {
retryCount: invocation.retryCount,
isRetry: invocation.isRetry,
}),
... (objectId === undefined ? {} : { objectId }),
};
const report = ( hookError : unknown ) : void =>
reportFailure (
'durability.alarm.retry_hook_failed' ,
{ retryInMs: info.retryInMs },
hookError
);
try {
const result = hook (info);
if (result !== undefined ) {
this . waitUntil ( Promise . resolve (result). catch (report), report);
}
} catch (hookError) {
report (hookError);
}
}
/**
* Runs every participant's due work, hands off to a fresh alarm if the
* invocation approaches its wall-time limit, and re-arms the physical alarm.
*/
private async runAlarm ( info ?: AlarmInvocationInfo ) : Promise < void > {
const startedAt = Date. now ();
const execution = Promise . all (
this .participants. map (( participant ) =>
participant. runDue (startedAt, info)
)
). then (() => undefined );
void execution. catch (() => undefined );
const handoff = Symbol ( 'alarm handoff' );
const remaining = this .alarmHandoffMs - (Date. now () - startedAt);
let timeout : ReturnType < typeof setTimeout> | undefined ;
let result : void | typeof handoff;
try {
result = await Promise . race ([
execution,
new Promise < typeof handoff>(( resolve ) => {
timeout = setTimeout (() => resolve (handoff), Math. max (remaining, 0 ));
}),
]);
} finally {
if (timeout !== undefined ) {
clearTimeout (timeout);
}
}
if (result === handoff) {
await this . armForHandoff (Date. now ());
return ;
}
await this . scheduleNextAlarm ();
}
/** Arms the physical alarm directly, outside any transaction; the next run reconciles properly. */
private async armFallbackAlarm () : Promise < boolean > {
try {
await this .storage. setAlarm (Date. now () + this .alarmMinDelayMs);
return true ;
} catch {
return false ;
}
}
private armForHandoff ( timestamp : number ) : Promise < void > {
return this .storage. transaction ( async ( physicalAlarm ) => {
const currentAlarm = await physicalAlarm. getAlarm ();
const target = Math. max (timestamp, Date. now () + this .alarmMinDelayMs);
if (currentAlarm === null || currentAlarm > target) {
await physicalAlarm. setAlarm (target);
}
});
}
/**
* Registers the execution in its active map synchronously, then claims and
* runs one attempt in the background. The returned promise settles when the
* attempt's outcome has been persisted, not when a timed-out handler settles.
*/
startExecution ( spec : ExecutionSpec , eagerPermit ?: () => void ) : Promise < void > {
const { key , row , policy } = spec;
let settleActive !: () => void ;
const settled = new Promise < void >(( resolve ) => {
settleActive = resolve;
});
const activeExecution : ActiveExecution = {
generation: row.generation_id,
createdAt: row.created_at,
settled,
};
spec.active. set (key, activeExecution);
this . trackActive (spec.table.name, key);
let releasePermit = eagerPermit;
let handlerStarted = false ;
let handlerSettled = false ;
let policySettled = false ;
const releaseIfSettled = () : void => {
if (policySettled && ( ! handlerStarted || handlerSettled)) {
releasePermit ?.();
settleActive ();
}
};
const execution = ( async () : Promise < void > => {
releasePermit ??= await this . acquirePermit ();
const attempt = claimAttempt (
this .sql,
spec.table,
key,
row.generation_id,
policy.maxAttempts
);
if (attempt === undefined ) {
await this . failExhausted (spec);
return ;
}
const startedAt = Date. now ();
spec. emit ({
... spec.entity,
type: 'attempt_started' ,
timestamp: startedAt,
attempt,
});
const controller = new AbortController ();
activeExecution.controller = controller;
let timeout : ReturnType < typeof setTimeout> | undefined ;
try {
handlerStarted = true ;
const handlerResult = Promise . resolve (). then (() =>
spec. invoke (attempt, controller.signal)
);
void handlerResult
. finally (() => {
handlerSettled = true ;
releaseIfSettled ();
})
. catch (() => undefined );
const timeoutResult = new Promise < never >(( _resolve , reject ) => {
timeout = setTimeout (() => {
const error = spec. timeoutError ();
controller. abort (error);
reject (error);
}, policy.attemptTimeoutMs);
});
const result = await Promise . race ([handlerResult, timeoutResult]);
// One transaction: a failed memo cleanup must undo the completion, not orphan the memos.
const timestamp = this . transactionSync (( sql ) => {
const completedAt = spec. complete (result, attempt);
if (completedAt !== undefined ) {
deleteMemos (sql, spec.table, key);
}
return completedAt;
});
if (timestamp !== undefined ) {
this . notify (spec.table, key, row);
spec. emit ({
... spec.entity,
type: 'attempt_settled' ,
timestamp,
attempt,
durationMs: Math. max ( 0 , timestamp - startedAt),
outcome: 'completed' ,
... (spec.includeResult ? { result } : {}),
});
}
} catch (caught) {
await this . settleFailure (spec, attempt, startedAt, caught);
} finally {
if (timeout !== undefined ) {
clearTimeout (timeout);
}
}
})(). finally (() => {
policySettled = true ;
releaseIfSettled ();
});
void settled. finally (() => {
if (spec.active. get (key) === activeExecution) {
spec.active. delete (key);
}
this . releaseActive (spec.table.name, key);
// Re-arm the in-memory timer now that this key is eligible again, so work
// the timer skipped while it executed is picked up at its exact time.
if ( this .liveTimers) {
void this . scheduleNextAlarm (). catch (( error ) =>
reportFailure (
'durability.scheduler.refresh_failed' ,
{ table: spec.table.name, key },
error
)
);
}
});
return execution;
}
private async failExhausted ( spec : ExecutionSpec ) : Promise < void > {
const { key , row , policy } = spec;
const exhausted = serializeError (spec. exhaustedError ());
const attempt = await this . transaction (( sql ) => {
const exhaustedAttempt = exhaustAttempts (
sql,
spec.table,
key,
row.generation_id,
policy.maxAttempts,
exhausted
);
if (exhaustedAttempt !== undefined ) {
deleteMemos (sql, spec.table, key);
}
return exhaustedAttempt;
});
if (attempt === undefined ) {
return ;
}
this . notify (spec.table, key);
spec. emit ({
... spec.entity,
type: 'terminal' ,
timestamp: Date. now (),
attempt,
reason: 'attempts_exhausted' ,
error: exhausted,
});
}
private async settleFailure (
spec : ExecutionSpec ,
attempt : number ,
startedAt : number ,
caught : unknown
) : Promise < void > {
const { key , row } = spec;
const decision = decideRetry (spec, attempt, caught);
const serializedError = serializeError (decision.error);
const updated = await this . transaction (( sql ) => {
const owned = updateAttempt (
sql,
spec.table,
{ key, generation: row.generation_id, attempt },
'status = ?, next_attempt_at = ?, last_error = ?, last_error_name = ?' ,
decision.terminal ? 'failed' : 'pending' ,
decision.nextAttemptAt,
serializedError.message,
serializedError.name
);
if (owned && decision.terminal) {
deleteMemos (sql, spec.table, key);
}
return owned;
});
if ( ! updated) {
return ;
}
if (decision.terminal) {
this . notify (spec.table, key);
}
const settled = {
... spec.entity,
type: 'attempt_settled' as const ,
timestamp: decision.timestamp,
attempt,
durationMs: Math. max ( 0 , decision.timestamp - startedAt),
error: serializedError,
};
spec. emit (
decision.terminal
? { ... settled, outcome: 'failed' }
: {
... settled,
outcome: 'retry_scheduled' ,
nextAttemptAt: decision.nextAttemptAt,
}
);
}
}
const engines = new WeakMap < DurabilityScheduler , Engine >();
/**
* Owns the Durable Object's physical alarm, storage, and concurrency
* pool so { @link Durability } and { @link DurabilityAlarms } can share them.
*
* Construct one per Durable Object when using both helpers, pass it to each,
* and delegate the object's `alarm` method to { @link DurabilityScheduler.alarm } .
* A helper constructed directly from the Durable Object context creates a
* private scheduler instead.
*
* @example
* ```ts
* class Jobs extends DurableObject<Env> {
* private readonly scheduler = new DurabilityScheduler({ context: this.ctx });
* private readonly durability = new Durability({
* scheduler: this.scheduler,
* handlers,
* });
* private readonly alarms = new DurabilityAlarms({
* scheduler: this.scheduler,
* handlers: alarmHandlers,
* });
*
* alarm(info?: AlarmInvocationInfo) {
* return this.scheduler.alarm(info);
* }
* }
* ```
*/
export class DurabilityScheduler {
constructor ( config : DurabilitySchedulerConfig ) {
engines. set ( this , new Engine (config.context, config));
}
/** Runs due work for every attached helper and re-arms the physical alarm. */
alarm ( info ?: AlarmInvocationInfo ) : Promise < void > {
return engineFor ({ scheduler: this }). alarm (info);
}
}
traceMethods ( DurabilityScheduler . prototype , 'DurabilityScheduler' );
/**
* Resolves the engine a helper should attach to: the shared one behind a
* scheduler, or a private one built from the Durable Object context.
*/
export const engineFor = ( attachment : SchedulerAttachment ) : Engine => {
if (attachment.scheduler === undefined ) {
return new Engine (attachment.context, attachment);
}
const engine = engines. get (attachment.scheduler);
if ( ! engine) {
throw new Error ( 'Unknown DurabilityScheduler instance' );
}
return engine;
}; import { traceLifecycle, traceMethods } from './dev.js' ;
import {
OutboxTimeoutError,
reportFailure,
serializeError,
type SerializedError,
} from './errors.js' ;
import type {
DurabilityLifecycleEvent,
Emit,
LifecycleHook,
OutboxLifecycleEntity,
} from './lifecycle.js' ;
import {
durabilityOutboxMigrations,
migrateCapability,
type DurabilityMigrationResult,
} from './migrations.js' ;
import {
assertPositiveInteger,
type DurabilityRetryOptions,
} from './policy.js' ;
import {
engineFor,
type Engine,
type SchedulerAttachment,
} from './scheduler.js' ;
import { deserialize, serialize } from './serialization.js' ;
import {
claimAttempt,
deleteAttempt,
updateAttempt,
type DurableTable,
} from './storage.js' ;
import { exponential, jitter } from './utils.js' ;
import { z } from 'zod' ;
const outboxTable : DurableTable = { name: 'durability_outbox' , key: 'id' };
const outboxRowSchema = z. object ({
id: z. string (),
channel: z. string (),
entry_key: z. string (),
version: z. number (). int (). positive (),
payload: z. string (),
status: z. literal ( 'pending' ),
attempt: z. number (). int (). nonnegative (),
next_attempt_at: z. number (). int (). nonnegative (),
last_error: z. string (). nullable (),
last_error_name: z. string (). nullable (),
created_at: z. number (). int (). nonnegative (),
generation_id: z. string (),
});
type OutboxRow = z . infer < typeof outboxRowSchema>;
const versionRowSchema = z. object ({ version: z. number (). int (). positive () });
const channelRowSchema = z. object ({ channel: z. string () });
const listDueForChannel = (
sql : SqlStorage ,
channel : string ,
now : number ,
limit : number ,
key ?: string
) : OutboxRow [] =>
outboxRowSchema. array (). parse (
sql
. exec (
`SELECT * FROM durability_outbox
WHERE channel = ? AND status = 'pending' AND next_attempt_at <= ?
AND (? IS NULL OR entry_key = ?)
ORDER BY next_attempt_at ASC, version ASC
LIMIT ?` ,
channel,
now,
key ?? null ,
key ?? null ,
limit
)
. toArray ()
);
/** Allocates a version from a durable counter that survives row deletion. */
const nextVersion = ( sql : SqlStorage ) : number => {
const { version } = versionRowSchema. parse (
sql
. exec (
`UPDATE durability_outbox_seq
SET next_version = next_version + 1
WHERE id = 1
RETURNING next_version - 1 AS version`
)
. toArray ()[ 0 ]
);
if ( ! Number. isSafeInteger (version + 1 )) {
throw new RangeError ( 'Outbox version exhausted' );
}
return version;
};
/** The newest staged version of one key, handed to `publish`. */
export type OutboxEntry < Value > = {
key : string ;
/** Monotonic across the whole Durable Object; newer stages are larger. */
version : number ;
value : Value ;
/** One-based publication attempt for this version. */
attempt : number ;
stagedAt : number ;
/**
* Keeps this entry pending instead of acknowledging it when `publish`
* returns. Only callable while `publish` is running.
*/
retry ( delayMs ?: number ) : void ;
};
export type DurabilityOutboxConfig < Value > = SchedulerAttachment & {
/**
* Stable channel ID. Outboxes on one scheduler share storage, partitioned by
* channel, and each owns its own `publish`. Defaults to `default`.
*/
channel ?: string ;
/**
* Publishes a batch of the newest pending versions. Returning acknowledges
* every entry not marked with `retry()`; throwing retries the whole batch.
* Sinks must be idempotent on `(key, version)` and keep the highest version.
*/
publish ( entries : OutboxEntry < Value >[]) : unknown | Promise < unknown >;
/** Defaults to 50. */
maxBatchSize ?: number ;
/**
* Rounds first publication up to the next multiple of this delay so stages
* within one window share a batch. Defaults to 0.
*/
batchDelayMs ?: number ;
/** Maximum duration of one `publish` call. Defaults to 60 seconds. */
attemptTimeoutMs ?: number ;
/** Publication is retried until acknowledged; only the delay is configurable. */
retries ?: Pick < DurabilityRetryOptions , 'delay' >;
onLifecycleEvent ?: LifecycleHook ;
};
/** A lifecycle event for one of this outbox's entries. */
export type DurabilityOutboxEvent = Extract <
DurabilityLifecycleEvent ,
{ entityKind : 'outbox_entry' }
>;
type Channels = Map < string , () => Promise < void >>;
/**
* Package-internal: stages inside a transaction another helper already holds,
* so its reports commit atomically with that helper's own rows. Assigned by
* the class's static block.
*/
export let stageWithin : < Value >(
outbox : DurabilityOutbox < Value >,
sql : SqlStorage ,
key : string ,
value : Value
) => number ;
/**
* Package-internal: drops a key's pending version inside a held transaction.
* An in-flight publication of it still completes, but its acknowledgement and
* retry both miss.
*/
export let discardWithin : (
outbox : DurabilityOutbox < never >,
sql : SqlStorage ,
key : string
) => void ;
/**
* Package-internal: publishes a key's pending version now, even one waiting
* out a retry delay or batch window, serialized with the channel's other
* publish runs, and reports whether nothing is left pending for it. Takes no
* scheduler permit, so a caller holding the last one cannot deadlock.
*/
export let publishNow : (
outbox : DurabilityOutbox < never >,
key : string
) => Promise < boolean >;
const infrastructureRetryMs = 60_000 ;
// setTimeout overflows past 2^31 - 1 ms, and it keeps every derived timestamp
// far inside the safe-integer range the storage indexes accept.
const maxDelayMs = 2 ** 31 - 1 ;
const orphanBatchSize = 50 ;
const channelsByEngine = new WeakMap < Engine , Channels >();
const rowId = ( channel : string , key : string ) : string =>
JSON . stringify ([channel, key]);
const entity = ( row : OutboxRow ) : OutboxLifecycleEntity => ({
entityKind: 'outbox_entry' ,
channel: row.channel,
id: row.entry_key,
generation: row.generation_id,
});
/**
* Rows of a channel no outbox registers would stay due forever and keep the
* alarm awake, so they are postponed like a removed fanout target's work.
*/
const postponeOrphans = async (
engine : Engine ,
channels : Channels
) : Promise < void > => {
const orphans = channelRowSchema
. array ()
. parse (
engine.sql
. exec ( 'SELECT DISTINCT channel FROM durability_outbox ORDER BY channel' )
. toArray ()
)
. map (( row ) => row.channel)
. filter (( channel ) => ! channels. has (channel));
for ( const channel of orphans) {
const due = listDueForChannel (
engine.sql,
channel,
Date. now (),
orphanBatchSize
);
if (due. length === 0 ) {
continue ;
}
reportFailure (
'durability.outbox.channel_missing' ,
{ channel },
new Error (
`Outbox channel "${ channel }" is no longer configured; retaining entries`
)
);
// eslint-disable-next-line no-await-in-loop
await engine. transaction (( sql ) => {
const postponedTo = Date. now () + infrastructureRetryMs;
for ( const row of due) {
updateAttempt (
sql,
outboxTable,
{ key: row.id, generation: row.generation_id, attempt: row.attempt },
'next_attempt_at = ?' ,
postponedTo
);
}
});
}
};
/**
* A composable DO capability: keyed latest-state publication. Stage the
* newest value for a key in the same transaction as the state it describes;
* the outbox republishes the newest version until `publish` acknowledges it.
* Superseded versions are coalesced and may never be published.
*/
export class DurabilityOutbox < Value = unknown > {
readonly #engine : Engine ;
readonly #channel : string ;
readonly #publish : DurabilityOutboxConfig < Value >[ 'publish' ];
readonly #maxBatchSize : number ;
readonly #batchDelayMs : number ;
readonly #attemptTimeoutMs : number ;
readonly #delay : ( attempt : number ) => number ;
readonly #emit : Emit ;
/** The channel's one publish run, never one still waiting for a permit. */
#running : Promise < void > | undefined ;
constructor ( config : DurabilityOutboxConfig < Value >) {
this .#channel = config.channel ?? 'default' ;
if ( typeof this .#channel !== 'string' || this .#channel. length === 0 ) {
throw new TypeError ( 'Outbox channel must be a non-empty string' );
}
if ( typeof config.publish !== 'function' ) {
throw new TypeError ( 'Outbox requires a publish function' );
}
this .#maxBatchSize = config.maxBatchSize ?? 50 ;
assertPositiveInteger ( 'maxBatchSize' , this .#maxBatchSize);
this .#batchDelayMs = config.batchDelayMs ?? 0 ;
if ( ! Number. isSafeInteger ( this .#batchDelayMs) || this .#batchDelayMs < 0 ) {
throw new RangeError ( 'batchDelayMs must be a non-negative integer' );
}
this .#attemptTimeoutMs = config.attemptTimeoutMs ?? 60_000 ;
assertPositiveInteger ( 'attemptTimeoutMs' , this .#attemptTimeoutMs);
if ( this .#attemptTimeoutMs > maxDelayMs) {
throw new RangeError ( `attemptTimeoutMs must be at most ${ maxDelayMs }` );
}
if ( this .#batchDelayMs > maxDelayMs) {
throw new RangeError ( `batchDelayMs must be at most ${ maxDelayMs }` );
}
this .#delay =
config.retries?.delay ?? (( attempt ) => jitter ( exponential (attempt)));
this .#publish = config.publish;
this .#engine = engineFor (config);
this .#emit = traceLifecycle ( this .#engine. emitter (config.onLifecycleEvent));
this .#engine. migrateSchema ( 'outbox' , durabilityOutboxMigrations);
this . #attach ();
}
static migrate (
context : Pick < DurableObjectState , 'storage' >,
target ?: string | null
) : DurabilityMigrationResult {
return migrateCapability (
context.storage,
'outbox' ,
durabilityOutboxMigrations,
target
);
}
alarm ( info ?: AlarmInvocationInfo ) : Promise < void > {
return this .#engine. alarm (info);
}
/**
* Durably stages the newest value for `key` and returns its version. On a
* SQLite-backed object, staging inside `ctx.storage.transaction()` commits
* atomically with the application's own writes in that transaction.
*/
async stage ( key : string , value : Value ) : Promise < number > {
const row = await this .#engine. transaction (( sql ) =>
this . #stageIn (sql, key, value)
);
this . #emitRegistered (row);
return row.version;
}
# stageIn ( sql : SqlStorage , key : string , value : Value ) : OutboxRow {
if ( typeof key !== 'string' || key. length === 0 ) {
throw new TypeError ( 'Outbox keys must be non-empty strings' );
}
const payload = serialize (value);
const version = nextVersion (sql);
const createdAt = Date. now ();
const staged : OutboxRow = {
id: rowId ( this .#channel, key),
channel: this .#channel,
entry_key: key,
version,
payload,
status: 'pending' ,
attempt: 0 ,
next_attempt_at: this . #firstAttemptAt (createdAt),
last_error: null ,
last_error_name: null ,
created_at: createdAt,
generation_id: String (version),
};
// Replacing the generation fences any in-flight publication of an older
// version: its acknowledgement can no longer delete this row.
sql. exec (
`INSERT OR REPLACE INTO durability_outbox
(id, channel, entry_key, version, payload, status, attempt,
next_attempt_at, last_error, last_error_name, created_at, generation_id)
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, NULL, NULL, ?, ?)` ,
staged.id,
staged.channel,
staged.entry_key,
staged.version,
staged.payload,
staged.next_attempt_at,
staged.created_at,
staged.generation_id
);
return staged;
}
# emitRegistered ( row : OutboxRow ) : void {
this . #emit ({
... entity (row),
type: 'registered' ,
timestamp: row.created_at,
attempt: 0 ,
});
}
static {
stageWithin = ( outbox , sql , key , value ) => {
const row = outbox. #stageIn (sql, key, value);
outbox. #emitRegistered (row);
return row.version;
};
discardWithin = ( outbox , sql , key ) => {
sql. exec (
'DELETE FROM durability_outbox WHERE id = ?' ,
rowId (outbox.#channel, key)
);
};
publishNow = ( outbox , key ) => outbox. #publishNow (key);
}
# attach () : void {
let channels = channelsByEngine. get ( this .#engine);
if ( ! channels) {
const created : Channels = new Map ();
const engine = this .#engine;
engine. register ({
table: outboxTable,
runDue : async () => {
await Promise . all ([ ... created. values ()]. map (( run ) => run ()));
await postponeOrphans (engine, created);
},
});
channelsByEngine. set (engine, created);
channels = created;
}
if (channels. has ( this .#channel)) {
throw new Error (
`An outbox for channel "${ this . #channel }" is already attached to this scheduler`
);
}
channels. set ( this .#channel, () => this . #runDue ());
}
// Rounding up to a shared boundary makes every stage in the window due together.
# firstAttemptAt ( createdAt : number ) : number {
if ( this .#batchDelayMs === 0 ) {
return createdAt;
}
return Math. ceil (createdAt / this .#batchDelayMs) * this .#batchDelayMs;
}
async # runDue () : Promise < void > {
if ( this .#running) {
return this .#running;
}
// Every transaction reconciles the alarm; one per idle run keeps re-arming
// it while anything else on the scheduler is due, such as a running operation.
if (
listDueForChannel ( this .#engine.sql, this .#channel, Date. now (), 1 )
. length === 0
) {
return undefined ;
}
// The permit is taken before claiming the channel, so nothing that holds
// the channel ever waits for a permit.
const release = await this .#engine. acquirePermit ();
try {
if ( this .#running) {
return await this .#running;
}
return await this . #exclusive (() => this . #runBatch ());
} finally {
release ();
}
}
async # publishNow ( key : string ) : Promise < boolean > {
const pending = () =>
this .#engine.sql
. exec (
'SELECT 1 FROM durability_outbox WHERE id = ?' ,
rowId ( this .#channel, key)
)
. toArray (). length > 0 ;
while ( this .#running) {
// eslint-disable-next-line no-await-in-loop
await this .#running. catch (() => undefined );
}
if ( ! pending ()) {
return true ;
}
await this . #exclusive (() => this . #runBatch (key));
return ! pending ();
}
# exclusive ( run : () => Promise < void >) : Promise < void > {
const running = run (). finally (() => {
if ( this .#running === running) {
this .#running = undefined ;
}
});
this .#running = running;
return running;
}
async # runBatch ( key ?: string ) : Promise < void > {
const claims = await this .#engine. transaction (( sql ) => {
// No run is in flight here, so a key's future attempt time is only a delay to skip.
const due = listDueForChannel (
sql,
this .#channel,
key === undefined ? Date. now () : Number.MAX_SAFE_INTEGER,
this .#maxBatchSize,
key
);
const deadline = Date. now () + this .#attemptTimeoutMs;
const claimed : OutboxRow [] = [];
for ( const row of due) {
// A crash mid-publication leaves the claim due again at the deadline.
const attempt = claimAttempt (
sql,
outboxTable,
row.id,
row.generation_id,
Number.MAX_SAFE_INTEGER,
deadline
);
if (attempt === undefined ) {
continue ;
}
claimed. push ({ ... row, attempt });
}
return claimed;
});
if (claims. length > 0 ) {
await this . #publishBatch (claims);
}
}
async # publishBatch ( claims : OutboxRow []) : Promise < void > {
const startedAt = Date. now ();
const retries = new Map < string , number | undefined >();
let open = true ;
const entries = claims. map (
( row ) : OutboxEntry < Value > => ({
key: row.entry_key,
version: row.version,
value: deserialize (row.payload) as Value ,
attempt: row.attempt,
stagedAt: row.created_at,
retry : ( delayMs ) => {
if ( ! open) {
throw new Error (
'Outbox entries can only be retried while publish is running'
);
}
if (
delayMs !== undefined &&
( ! Number. isSafeInteger (delayMs) ||
delayMs < 0 ||
delayMs > maxDelayMs)
) {
throw new RangeError (
`delayMs must be an integer from 0 to ${ maxDelayMs }`
);
}
retries. set (row.id, delayMs);
},
})
);
for ( const row of claims) {
this . #emit ({
... entity (row),
type: 'attempt_started' ,
timestamp: startedAt,
attempt: row.attempt,
});
}
let failure : SerializedError | undefined ;
try {
await this . #deadline (() => this . #publish (entries));
} catch (error) {
failure = serializeError (error);
} finally {
open = false ;
}
const retryError = failure ?? {
name: 'Retry' ,
message: 'Retried by publisher' ,
};
const acknowledged : OutboxRow [] = [];
const retried : Array <{ row : OutboxRow ; nextAttemptAt : number }> = [];
await this .#engine. transaction (( sql ) => {
for ( const row of claims) {
const ref = {
key: row.id,
generation: row.generation_id,
attempt: row.attempt,
};
if (failure === undefined && ! retries. has (row.id)) {
if ( deleteAttempt (sql, outboxTable, ref)) {
acknowledged. push (row);
}
continue ;
}
const nextAttemptAt =
Date. now () + (retries. get (row.id) ?? this . #retryDelay (row.attempt));
const settled = updateAttempt (
sql,
outboxTable,
ref,
'next_attempt_at = ?, last_error = ?, last_error_name = ?' ,
nextAttemptAt,
retryError.message,
retryError.name
);
if (settled) {
retried. push ({ row, nextAttemptAt });
}
}
});
const timestamp = Date. now ();
const durationMs = Math. max ( 0 , timestamp - startedAt);
for ( const row of acknowledged) {
this . #emit ({
... entity (row),
type: 'attempt_settled' ,
timestamp,
attempt: row.attempt,
durationMs,
outcome: 'completed' ,
});
}
for ( const { row , nextAttemptAt } of retried) {
this . #emit ({
... entity (row),
type: 'attempt_settled' ,
timestamp,
attempt: row.attempt,
durationMs,
outcome: 'retry_scheduled' ,
nextAttemptAt,
error: retryError,
});
}
}
// A broken delay policy must not strand the entry: fall back to a fixed delay.
# retryDelay ( attempt : number ) : number {
try {
const delay = Math. round ( this . #delay (attempt));
if (Number. isSafeInteger (delay) && delay >= 0 && delay <= maxDelayMs) {
return delay;
}
} catch (error) {
reportFailure (
'durability.outbox.retry_policy_failed' ,
{ channel: this .#channel },
error
);
}
return infrastructureRetryMs;
}
async # deadline ( run : () => unknown ) : Promise < void > {
let timer : ReturnType < typeof setTimeout> | undefined ;
try {
await Promise . race ([
Promise . resolve (). then (run),
new Promise < never >(( _resolve , reject ) => {
timer = setTimeout (
() => reject ( new OutboxTimeoutError ( this .#attemptTimeoutMs)),
this .#attemptTimeoutMs
);
}),
]);
} finally {
if (timer !== undefined ) {
clearTimeout (timer);
}
}
}
}
traceMethods ( DurabilityOutbox . prototype , 'DurabilityOutbox' ); import {
TopologyUnavailableError,
type TopologyFallbackReason,
} from './errors.js' ;
import { assertPositiveInteger } from './policy.js' ;
import {
decodeSnapshot,
type TopologyMemberInput,
type TopologySnapshot,
} from './topology-snapshot.js' ;
export type TopologySource = 'published' | 'stale' | 'seed' | 'fallback' ;
export type TopologyView < Meta = undefined > = TopologySnapshot < Meta > & {
source : TopologySource ;
fetchedAt : number ;
};
export type TopologyClientConfig < Meta = undefined > = {
/** Returns the `encoded` value the coordinator's publisher stored, or null. */
read ( group : string ) : Promise < string | null | undefined >;
/** Defaults to 1 second. */
readTimeoutMs ?: number ;
/** Defaults to 30 seconds, jittered by ±20% so isolates do not refresh together. */
ttlMs ?: number ;
/** How long a missing or failed read is remembered before reading again. Defaults to 5 seconds. */
negativeTtlMs ?: number ;
/** Opt-in: serve this isolate's last good snapshot for this long after it expires when a read fails. */
staleIfErrorMs ?: number ;
/** Opt-in: members derived locally, without I/O. */
seed ? ( group : string ) : readonly TopologyMemberInput < Meta >[] | undefined ;
/** Opt-in and application-owned. Not cached: whatever it calls runs on every fallback. */
fallback ? (
group : string ,
reason : TopologyFallbackReason
) : Promise < TopologySnapshot < Meta > | undefined >;
onFallback ? ( event : {
group : string ;
reason : TopologyFallbackReason ;
source : TopologySource | 'none' ;
}) : void ;
};
type Entry < Meta > = {
/** The last good published snapshot. */
view ?: TopologyView < Meta >;
staleUntil : number ;
/** When the next get reads the store again. */
expiresAt : number ;
failure ?: { reason : TopologyFallbackReason ; cause ?: unknown };
};
// High-cardinality groups (one per tenant) must not grow an isolate without bound; the least recently used go first.
const maxCachedGroups = 1_024 ;
class ReadTimeoutError extends Error {}
const jittered = ( ms : number ) : number =>
Math. round (ms * ( 0.8 + Math. random () * 0.4 ));
/** Read-only. Created by `DurabilityTopology.client`. */
export class TopologyClient < Meta = undefined > {
readonly #config : TopologyClientConfig < Meta >;
readonly #readTimeoutMs : number ;
readonly #ttlMs : number ;
readonly #negativeTtlMs : number ;
readonly #cache = new Map < string , Entry < Meta >>();
readonly #inflight = new Map < string , Promise < TopologyView < Meta >>>();
constructor ( config : TopologyClientConfig < Meta >) {
if ( typeof config.read !== 'function' ) {
throw new TypeError ( 'A topology client needs a read function' );
}
this .#readTimeoutMs = config.readTimeoutMs ?? 1_000 ;
this .#ttlMs = config.ttlMs ?? 30_000 ;
this .#negativeTtlMs = config.negativeTtlMs ?? 5_000 ;
assertPositiveInteger ( 'readTimeoutMs' , this .#readTimeoutMs);
assertPositiveInteger ( 'ttlMs' , this .#ttlMs);
assertPositiveInteger ( 'negativeTtlMs' , this .#negativeTtlMs);
if (
config.staleIfErrorMs !== undefined &&
( ! Number. isSafeInteger (config.staleIfErrorMs) ||
config.staleIfErrorMs < 0 )
) {
throw new RangeError ( 'staleIfErrorMs must be a non-negative integer' );
}
this .#config = config;
}
/**
* Returns the group's snapshot from this isolate's cache or the read store.
* Concurrent calls share one read, and a newer snapshot is never replaced by
* an older one. Throws { @link TopologyUnavailableError } when the published
* snapshot is unusable and no opt-in fallback produced one.
*/
get ( group : string ) : Promise < TopologyView < Meta >> {
const entry = this .#cache. get (group);
if (entry && Date. now () < entry.expiresAt) {
if (entry.failure) {
return this . #fallback (group, entry);
}
if (entry.view) {
this . #remember (group, entry);
return Promise . resolve (entry.view);
}
}
const inflight = this .#inflight. get (group);
if (inflight) {
return inflight;
}
const run = this . #load (group). finally (() => this .#inflight. delete (group));
this .#inflight. set (group, run);
return run;
}
async # load ( group : string ) : Promise < TopologyView < Meta >> {
const previous = this .#cache. get (group);
const result = await this . #read (group);
const now = Date. now ();
if ( 'snapshot' in result) {
const { snapshot } = result;
// The store is eventually consistent; a lagging replica must not move readers backwards.
// Versions only compare within one coordinator: a group moved to another starts over.
const keep =
previous?.view !== undefined &&
previous.view.coordinator === snapshot.coordinator &&
previous.view.version > snapshot.version;
const view = keep
? previous.view !
: { ... snapshot, source: 'published' as const , fetchedAt: now };
const expiresAt = now + jittered ( this .#ttlMs);
this . #remember (group, {
view,
expiresAt,
staleUntil: expiresAt + ( this .#config.staleIfErrorMs ?? 0 ),
});
return view;
}
const entry : Entry < Meta > = {
// A missing group is not an error, so nothing stale may be served for it.
... (result.failure.reason === 'missing' || ! previous?.view
? {}
: { view: previous.view }),
staleUntil: previous?.staleUntil ?? 0 ,
expiresAt: now + jittered ( this .#negativeTtlMs),
failure: result.failure,
};
this . #remember (group, entry);
return this . #fallback (group, entry);
}
async # read (
group : string
) : Promise <
| { snapshot : TopologySnapshot < Meta > }
| { failure : NonNullable < Entry < Meta >[ 'failure' ]> }
> {
let encoded : string | null | undefined ;
let timer : ReturnType < typeof setTimeout> | undefined ;
try {
encoded = await Promise . race ([
Promise . resolve (). then (() => this .#config. read (group)),
new Promise < never >(( _resolve , reject ) => {
timer = setTimeout (
() => reject ( new ReadTimeoutError ()),
this .#readTimeoutMs
);
}),
]);
} catch (cause) {
return {
failure: {
reason: cause instanceof ReadTimeoutError ? 'timeout' : 'error' ,
cause,
},
};
} finally {
clearTimeout (timer);
}
if (encoded === null || encoded === undefined ) {
return { failure: { reason: 'missing' } };
}
try {
const snapshot = decodeSnapshot < Meta >(encoded);
if (snapshot.group !== group) {
throw new Error ( `Snapshot belongs to group "${ snapshot . group }"` );
}
return { snapshot };
} catch (cause) {
return { failure: { reason: 'invalid' , cause } };
}
}
async # fallback (
group : string ,
entry : Entry < Meta >
) : Promise < TopologyView < Meta >> {
const { reason , cause } = entry.failure ! ;
const notify = ( source : TopologySource | 'none' ) =>
this .#config. onFallback ?.({ group, reason, source });
if (
this .#config.staleIfErrorMs !== undefined &&
entry.view !== undefined &&
Date. now () < entry.staleUntil
) {
notify ( 'stale' );
return { ... entry.view, source: 'stale' };
}
const seeded = this .#config. seed ?.(group);
if (seeded !== undefined && seeded. length > 0 ) {
notify ( 'seed' );
return {
group,
version: 0 ,
coordinator: '' ,
members: seeded
. map (( member ) => ({ ... member, joinedAt: 0 }))
. sort (( a , b ) => (a.id < b.id ? - 1 : 1 )),
source: 'seed' ,
fetchedAt: Date. now (),
};
}
const fallback = await this .#config. fallback ?.(group, reason);
if (fallback !== undefined ) {
notify ( 'fallback' );
return { ... fallback, source: 'fallback' , fetchedAt: Date. now () };
}
notify ( 'none' );
throw new TopologyUnavailableError (group, reason, { cause });
}
# remember ( group : string , entry : Entry < Meta >) : void {
this .#cache. delete (group);
this .#cache. set (group, entry);
if ( this .#cache.size > maxCachedGroups) {
const oldest = this .#cache. keys (). next ().value;
if (oldest !== undefined ) {
this .#cache. delete (oldest);
}
}
}
} import { z } from 'zod' ;
/** `active` members are in the write and read sets; `draining` members only in the read set. */
export type TopologyMemberState = 'active' | 'draining' ;
type MetaField < Meta > = undefined extends Meta
? { meta ?: Meta }
: { meta : Meta };
/** A member as the application declares it. */
export type TopologyMemberInput < Meta = undefined > = {
/** The member's Durable Object name. */
id : string ;
state : TopologyMemberState ;
locationHint ?: DurableObjectLocationHint ;
} & MetaField < Meta >;
export type TopologyMember < Meta = undefined > = TopologyMemberInput < Meta > & {
joinedAt : number ;
};
export type TopologySnapshot < Meta = undefined > = {
group : string ;
/** Increases on every membership change, and keeps increasing across deletes. */
version : number ;
/** ID of the coordinator Durable Object that published this snapshot. */
coordinator : string ;
/** Sorted by ID. */
members : readonly TopologyMember < Meta >[];
};
const locationHints = [
'wnam' ,
'enam' ,
'sam' ,
'weur' ,
'eeur' ,
'apac' ,
'oc' ,
'afr' ,
'me' ,
] as const satisfies readonly DurableObjectLocationHint [];
// Unknown keys are stripped, so current members (which carry `joinedAt`) can be returned as input.
export const memberInputSchema = z. object ({
id: z. string (). min ( 1 ),
state: z. enum ([ 'active' , 'draining' ]),
locationHint: z. enum (locationHints). optional (),
meta: z. json (). optional (),
});
const memberSchema = memberInputSchema. extend ({
joinedAt: z. number (). int (). nonnegative (),
});
export const membersSchema = memberSchema
. array ()
. superRefine (( members , ctx ) => {
for ( let index = 1 ; index < members. length ; index ++ ) {
if (members[index - 1 ] ! .id >= members[index] ! .id) {
ctx. addIssue ({
code: 'custom' ,
message: 'Members must be sorted by ID and unique' ,
path: [index, 'id' ],
});
}
}
});
const encodedSchema = z. object ({
format: z. literal ( 1 ),
group: z. string (). min ( 1 ),
version: z. number (). int (). positive (),
coordinator: z. string (). min ( 1 ),
members: membersSchema. min ( 1 ),
});
export const encodeSnapshot = < Meta >(
snapshot : TopologySnapshot < Meta >
) : string => JSON . stringify ({ format: 1 , ... snapshot });
/** The stored value crossed a trust boundary, so every field is re-validated. */
export const decodeSnapshot = < Meta >(
encoded : string
) : TopologySnapshot < Meta > => {
const { format : _format , ... snapshot } = encodedSchema. parse (
JSON . parse (encoded)
);
return snapshot as unknown as TopologySnapshot < Meta >;
};