examples/event-topics/worker.ts packages/durability/src/log.ts packages/durability/src/durability-alarms.ts packages/durability/src/scheduler.ts examples/event-topics/worker.ts Open source ↗ import { DurableObject } from 'cloudflare:workers' ;
import { timeout } from '@durability/transforms' ;
import {
DurabilityAlarms,
DurabilityLog,
DurabilityScheduler,
type LogAppendInput,
type LogPage,
type LogRecord,
} from 'durability' ;
import { DurabilityRouting } from 'durability/routing' ;
type Env = {
COLD : R2Bucket ;
PARTITIONS : DurableObjectNamespace < Partition >;
};
type Event = {
topic : string ;
/** Events sharing a partition key land on the same partition. */
partitionKey : string ;
/** Already-encoded body: a log stores bytes, not the producer's types. */
payload : string ;
};
type Append = {
events : LogAppendInput < Event >[];
region ?: DurableObjectLocationHint ;
};
/** Partitions per topic. Adding one is a routing decision, not a deploy. */
const partitionCount = 8 ;
const coldPrefix = 'segments/' ;
// Retention deletes by age and count. It is not Kafka-style key compaction,
// which keeps the latest record per key and is not implemented here.
const retentionSweepMs = 60_000 ;
const partitionFor = ( key : string ) : number => {
let hash = 2_166_136_261 ;
for ( let index = 0 ; index < key. length ; index += 1 ) {
hash ^= key. charCodeAt (index);
hash = Math. imul (hash, 16_777_619 );
}
return Math. abs (hash) % partitionCount;
};
/**
* One partition of one topic. The application owns this class and composes the
* capabilities it wants: a log for retained history, alarms for compaction, and
* routing's load observer so producers can see this partition's backlog.
*/
export class Partition extends DurableObject < Env > {
private readonly scheduler = new DurabilityScheduler ({ context: this .ctx });
private readonly routing = new DurabilityRouting ();
private readonly log = new DurabilityLog < Event >({
scheduler: this .scheduler,
routing: this .routing,
retention: {
maxAgeMs: 7 * 24 * 60 * 60 * 1_000 ,
maxRecords: 50_000 ,
// The object keeps a hot window; everything older lives in R2.
maxBytes: 256 * 1024 * 1024 ,
},
// Flushed segments stay readable, so a consumer that falls behind the hot
// window is served from R2 instead of losing records.
cold: {
write : async ({ records , firstOffset }) => {
const locator = `${ coldPrefix }${ this . ctx . id . toString () }/${ firstOffset }` ;
await this .env. COLD . put (locator, JSON . stringify (records), {
onlyIf: { etagDoesNotMatch: '*' },
});
return locator;
},
read : async ( locator ) => {
const stored = await this .env. COLD . get (locator);
return stored ? await stored. json < LogRecord < Event >[]>() : undefined ;
},
},
});
// Retention needs a schedule, and a named alarm is that schedule.
private readonly alarms = new DurabilityAlarms ({
scheduler: this .scheduler,
handlers: {
enforceRetention : async () => {
await this .log. trim ();
// Compare the hot window, not the oldest readable offset: flushed
// segments keep that one low forever, which would sweep forever.
const { hotOffset , nextOffset } = await this .log. bounds ();
if (hotOffset < nextOffset) {
await this .alarms. enforceRetention (Date. now () + retentionSweepMs);
return ;
}
// Nothing left to expire, so stop re-arming and let the partition
// hibernate until the next append schedules another sweep.
await this .ctx.storage. delete ( 'sweeping' );
},
},
});
/** Producers reach this through routing; the result carries acceptance + load. */
async append ( batch : Append ) {
const result = await this .log. append (batch.events);
// Scheduling a named alarm replaces its pending time, so re-arming on
// every append would postpone retention for as long as traffic continues.
if (result.success && ! ( await this .ctx.storage. get < boolean >( 'sweeping' ))) {
await this .ctx.storage. put ( 'sweeping' , true );
await this .alarms. enforceRetention (Date. now () + retentionSweepMs);
}
return result;
}
/**
* Consumers pull. A group's cursor is the only thing that advances, and a
* cursor below the hot window is served from R2 rather than failing.
*/
async consume ( group : string , limit : number ) : Promise < LogPage < Event >> {
// A new group starts at the oldest readable record, flushed or not.
const from =
( await this .log. cursor (group)) ?? ( await this .log. bounds ()).oldestOffset;
return this .log. read ({ from, limit });
}
/**
* Mirrors the bucket's lifecycle rule. R2 expires the objects on its own
* schedule, so the index must forget the same segments or a read would
* chase a locator whose object is gone.
*/
expireCold ( before : number ) {
return this .log. forgetColdBefore (before);
}
/** Acknowledging is separate from reading, so a crash re-reads the page. */
commit ( group : string , offset : number ) {
return this .log. commit (group, offset);
}
cursors () {
return this .log. cursors ();
}
bounds () {
return this .log. bounds ();
}
load () {
return this .log. load ();
}
alarm ( info ?: AlarmInvocationInfo ) {
return this .scheduler. alarm (info);
}
}
/**
* Stands in for the consumer's real work. Records arrive in offset order
* within a partition, and may be redelivered after a failure.
*/
const handle = async ( records : LogRecord < Event >[]) : Promise < void > => {
for ( const record of records) {
console. log ( `handled ${ record . offset } of ${ record . body . topic }` );
}
};
/**
* Producer side. The shard key is `topic:partition`, so topics are created by
* writing to them: no partition table, no rebalance, and idle partitions
* hibernate. `invoke` picks the application method, so routing works with the
* log exactly as it does with fanout.
*/
const topics = DurabilityRouting. client ({
target: Partition,
invoke : ( stub , input : Append ) => stub. append (input),
sharding : ( input ) => {
const first = input.events[ 0 ]?.body;
if ( ! first) {
throw new Error ( 'An append must carry at least one event' );
}
const shard = `${ first . topic }:${ partitionFor ( first . partitionKey ) }` ;
return input.region === undefined
? shard
: { shard, locationHint: input.region };
},
});
export default {
async fetch ( request , env ) : Promise < Response > {
const url = new URL (request.url);
const topic = url.searchParams. get ( 'topic' ) ?? 'events' ;
// Demo input only: authenticate and validate untrusted payloads.
if (request.method === 'POST' ) {
const events = await request.json <
Array < { partitionKey : string; payload : string; key ?: string } >
> ();
// A batch must share a partition, so group by key before pushing.
const byPartition = new Map < string , LogAppendInput < Event >[]>();
for ( const event of events) {
const shard = `${ topic }:${ partitionFor ( event . partitionKey ) }` ;
const body : Event = {
topic,
partitionKey: event.partitionKey,
payload: event.payload,
};
byPartition. set (shard, [
... (byPartition. get (shard) ?? []),
event.key === undefined
? { body }
: { deduplicationKey: event.key, body },
]);
}
const accepted = await Promise . all (
[ ... byPartition. values ()]. map (( group ) =>
topics. with (timeout, 5_000 ). push ({ events: group })
)
);
// A rejected push is definite; a thrown one is uncertain and may retry.
return Response. json (accepted, {
status: accepted. every (( result ) => result.success) ? 202 : 409 ,
});
}
if (request.method !== 'GET' ) {
return new Response ( 'POST events or GET ?group=' , { status: 405 });
}
// Each partition is polled independently: a slow group never blocks others.
const group = url.searchParams. get ( 'group' ) ?? 'default' ;
const partitions = [ ... Array (partitionCount). keys ()]. map (( index ) =>
env. PARTITIONS . getByName ( `${ topic }:${ index }` )
);
const pages = await Promise . all (
partitions. map ( async ( stub ) => {
const page = await stub. consume (group, 100 );
if (page.records. length === 0 ) {
return { nextOffset: page.nextOffset, lag: page.lag, handled: 0 };
}
// Commit only once the work succeeded. Committing first would make
// this at-most-once, silently skipping a page on a crash. Throwing
// instead re-reads it, so processing must tolerate duplicates.
await handle (page.records);
await stub. commit (group, page.nextOffset);
return {
nextOffset: page.nextOffset,
lag: page.lag,
handled: page.records. length ,
};
})
);
return Response. json (pages);
},
} satisfies ExportedHandler < Env >; import { z } from 'zod' ;
import { traceMethods } from './dev.js' ;
import {
LogAppendError,
LogLeaseError,
LogTruncatedError,
reportFailure,
serializeError,
} from './errors.js' ;
import { RoutingLoad, type EnqueueResult, type LoadSnapshot } from './load.js' ;
import {
durabilityLogLeaseMigrations,
durabilityLogMigrations,
migrateCapability,
} from './migrations.js' ;
import { assertPositiveInteger } from './policy.js' ;
import {
engineFor,
type Engine,
type SchedulerAttachment,
} from './scheduler.js' ;
import { deserialize, serialize } from './serialization.js' ;
const logRecordRowSchema = z. object ({
offset: z. number (). int (). nonnegative (),
dedup_key: z. string (),
payload: z. string (),
bytes: z. number (). int (). nonnegative (),
appended_at: z. number (). int (). nonnegative (),
});
type LogRecordRow = z . infer < typeof logRecordRowSchema>;
const logCursorRowSchema = z. object ({
consumer: z. string (),
offset: z. number (). int (). nonnegative (),
committed_at: z. number (). int (). nonnegative (),
});
/** One contiguous run of records moved out of the object into cold storage. */
const logSegmentRowSchema = z. object ({
first_offset: z. number (). int (). nonnegative (),
last_offset: z. number (). int (). nonnegative (),
locator: z. string (),
bytes: z. number (). int (). nonnegative (),
flushed_at: z. number (). int (). nonnegative (),
});
type LogSegmentRow = z . infer < typeof logSegmentRowSchema>;
/**
* One in-flight or settled range of offsets held by a consumer. A range is
* redelivered under a fresh `batch_id`, so an ack from a previous holder is
* rejected rather than advancing a range someone else now owns.
*/
const logLeaseRowSchema = z. object ({
batch_id: z. string (),
consumer: z. string (),
first_offset: z. number (). int (). nonnegative (),
last_offset: z. number (). int (). nonnegative (),
state: z. enum ([ 'active' , 'pending' , 'acked' , 'skipped' ]),
attempt: z. number (). int (). positive (),
expires_at: z. number (). int (). nonnegative (),
created_at: z. number (). int (). nonnegative (),
});
export type LogLeaseRow = z . infer < typeof logLeaseRowSchema>;
type LogLeaseState = LogLeaseRow [ 'state' ];
const allocationRowSchema = z. object ({
allocated_offset: z. number (). int (). nonnegative (),
});
const offsetRowSchema = z. object ({ offset: z. number (). int (). nonnegative () });
const seqRowSchema = z. object ({ seq: z. number (). int (). nonnegative () });
const countRowSchema = z. object ({ count: z. number (). int (). nonnegative () });
const totalBytesRowSchema = z. object ({
total_bytes: z. number (). int (). nonnegative (),
});
const boundsRowSchema = z. object ({
hot: z. number (). int (). nonnegative (). nullable (),
cold: z. number (). int (). nonnegative (). nullable (),
next_offset: z. number (). int (). nonnegative (),
});
export type LogBounds = {
/**
* Lowest readable offset, counting flushed segments that can be rehydrated.
* Equals `nextOffset` once nothing is readable.
*/
oldestOffset : number ;
/** Lowest offset still held in the object rather than cold storage. */
hotOffset : number ;
/** Offset the next appended record receives. */
nextOffset : number ;
};
/**
* Inserts the batch and returns each record's offset in input order. A record
* whose deduplication key is already present keeps its original offset and
* consumes no new one, so a repeated append is idempotent while that record is
* still held in the object. Runs inside the caller's transaction.
*/
const appendRecords = (
sql : SqlStorage ,
records : readonly Pick < LogRecordRow , 'dedup_key' | 'payload' | 'bytes' >[],
appendedAt : number
) : number [] => {
const assigned = new Map < string , number >();
for ( const record of records) {
const existing = offsetRowSchema. optional (). parse (
sql
. exec (
`SELECT "offset" FROM durability_log_records
WHERE dedup_key = ? LIMIT 1` ,
record.dedup_key
)
. toArray ()[ 0 ]
);
if (existing !== undefined ) {
assigned. set (record.dedup_key, existing.offset);
}
}
const fresh = records. filter (( record ) => ! assigned. has (record.dedup_key));
const distinct = [ ...new Map (fresh. map (( row ) => [row.dedup_key, row]))];
if (distinct. length === 0 ) {
return records. map (( record ) => assigned. get (record.dedup_key) ! );
}
const first = seqRowSchema. parse (
sql
. exec (
`UPDATE durability_log_seq
SET next_offset = next_offset + ?
WHERE id = 1
RETURNING next_offset - ? AS seq` ,
distinct. length ,
distinct. length
)
. toArray ()[ 0 ]
).seq;
let added = 0 ;
distinct. forEach (([ dedupKey , row ], index ) => {
const offset = first + index;
assigned. set (dedupKey, offset);
added += row.bytes;
sql. exec (
`INSERT INTO durability_log_records
("offset", dedup_key, payload, bytes, appended_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(dedup_key) DO NOTHING` ,
offset,
dedupKey,
row.payload,
row.bytes,
appendedAt
);
});
sql. exec (
`UPDATE durability_log_seq SET total_bytes = total_bytes + ?
WHERE id = 1` ,
added
);
return records. map (( record ) => assigned. get (record.dedup_key) ! );
};
const readRecords = (
sql : SqlStorage ,
from : number ,
limit : number
) : LogRecordRow [] =>
logRecordRowSchema. array (). parse (
sql
. exec (
`SELECT * FROM durability_log_records
WHERE "offset" >= ?
ORDER BY "offset" ASC
LIMIT ?` ,
from,
limit
)
. toArray ()
);
const readBounds = ( sql : SqlStorage ) : LogBounds => {
const row = boundsRowSchema. parse (
sql
. exec (
`SELECT
(SELECT MIN("offset") FROM durability_log_records) AS hot,
(SELECT MIN(first_offset) FROM durability_log_segments) AS cold,
next_offset
FROM durability_log_seq WHERE id = 1`
)
. toArray ()[ 0 ]
);
const hotOffset = row.hot ?? row.next_offset;
return {
oldestOffset: Math. min (row.cold ?? hotOffset, hotOffset),
hotOffset,
nextOffset: row.next_offset,
};
};
const countRecords = ( sql : SqlStorage ) : number =>
countRowSchema. parse (
sql
. exec ( 'SELECT COUNT(*) AS count FROM durability_log_records' )
. toArray ()[ 0 ]
).count;
/** Bytes of record payloads held in the object, excluding flushed segments. */
const totalBytes = ( sql : SqlStorage ) : number =>
totalBytesRowSchema. parse (
sql
. exec ( 'SELECT total_bytes FROM durability_log_seq WHERE id = 1' )
. toArray ()[ 0 ]
).total_bytes;
/** Oldest retained records in offset order, for flush-or-delete decisions. */
const listOldest = ( sql : SqlStorage , limit : number ) : LogRecordRow [] =>
logRecordRowSchema. array (). parse (
sql
. exec (
`SELECT * FROM durability_log_records
ORDER BY "offset" ASC
LIMIT ?` ,
limit
)
. toArray ()
);
/**
* Deletes at most `limit` records below `through`, oldest first, and returns
* how many were removed. Runs inside the caller's transaction.
*/
const trimThrough = (
sql : SqlStorage ,
through : number ,
limit : number
) : number => {
const removed = logRecordRowSchema. array (). parse (
sql
. exec (
`DELETE FROM durability_log_records
WHERE "offset" IN (
SELECT "offset" FROM durability_log_records
WHERE "offset" < ?
ORDER BY "offset" ASC
LIMIT ?
)
RETURNING *` ,
through,
limit
)
. toArray ()
);
if (removed. length === 0 ) {
return 0 ;
}
sql. exec (
`UPDATE durability_log_seq SET total_bytes = MAX(0, total_bytes - ?)
WHERE id = 1` ,
removed. reduce (( total , row ) => total + row.bytes, 0 )
);
return removed. length ;
};
/** Records where a flushed run now lives, so a read can rehydrate it. */
const insertSegment = ( sql : SqlStorage , row : LogSegmentRow ) : void => {
sql. exec (
`INSERT INTO durability_log_segments
(first_offset, last_offset, locator, bytes, flushed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(first_offset) DO UPDATE SET
last_offset = excluded.last_offset,
locator = excluded.locator,
bytes = excluded.bytes,
flushed_at = excluded.flushed_at` ,
row.first_offset,
row.last_offset,
row.locator,
row.bytes,
row.flushed_at
);
};
/** The segment holding an offset, if that offset was flushed. */
const findSegment = (
sql : SqlStorage ,
offset : number
) : LogSegmentRow | undefined =>
logSegmentRowSchema. optional (). parse (
sql
. exec (
`SELECT * FROM durability_log_segments
WHERE first_offset <= ? AND last_offset >= ?
LIMIT 1` ,
offset,
offset
)
. toArray ()[ 0 ]
);
const readCursors = ( sql : SqlStorage ) =>
logCursorRowSchema
. array ()
. parse (
sql
. exec ( 'SELECT * FROM durability_log_cursors ORDER BY consumer' )
. toArray ()
);
const readCursor = ( sql : SqlStorage , consumer : string ) : number | undefined =>
offsetRowSchema. optional (). parse (
sql
. exec (
`SELECT "offset" FROM durability_log_cursors
WHERE consumer = ? LIMIT 1` ,
consumer
)
. toArray ()[ 0 ]
)?.offset;
/** Advances a cursor, ignoring regressions so a late commit cannot rewind it. */
const commitCursor = (
sql : SqlStorage ,
consumer : string ,
offset : number ,
committedAt : number
) : void => {
sql. exec (
`INSERT INTO durability_log_cursors (consumer, "offset", committed_at)
VALUES (?, ?, ?)
ON CONFLICT(consumer) DO UPDATE
SET "offset" = excluded."offset", committed_at = excluded.committed_at
WHERE excluded."offset" > durability_log_cursors."offset"` ,
consumer,
offset,
committedAt
);
};
/** Highest offset handed to a lease, which runs ahead of the commit. */
const readAllocation = (
sql : SqlStorage ,
consumer : string
) : number | undefined =>
allocationRowSchema. optional (). parse (
sql
. exec (
`SELECT allocated_offset FROM durability_log_allocations
WHERE consumer = ? LIMIT 1` ,
consumer
)
. toArray ()[ 0 ]
)?.allocated_offset;
const setAllocation = (
sql : SqlStorage ,
consumer : string ,
offset : number
) : void => {
sql. exec (
`INSERT INTO durability_log_allocations (consumer, allocated_offset)
VALUES (?, ?)
ON CONFLICT(consumer) DO UPDATE SET allocated_offset = excluded.allocated_offset` ,
consumer,
offset
);
};
const insertLease = ( sql : SqlStorage , row : LogLeaseRow ) : void => {
sql. exec (
`INSERT INTO durability_log_leases
(batch_id, consumer, first_offset, last_offset, state, attempt,
expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ,
row.batch_id,
row.consumer,
row.first_offset,
row.last_offset,
row.state,
row.attempt,
row.expires_at,
row.created_at
);
};
/**
* Oldest range owed redelivery: explicitly nacked, or held past its expiry.
* Claiming rewrites its `batch_id`, fencing the previous holder.
*/
const claimExpired = (
sql : SqlStorage ,
consumer : string ,
now : number ,
batchId : string ,
expiresAt : number
) : LogLeaseRow | undefined =>
logLeaseRowSchema. optional (). parse (
sql
. exec (
`UPDATE durability_log_leases
SET batch_id = ?, state = 'active', attempt = attempt + 1,
expires_at = ?
WHERE batch_id = (
SELECT batch_id FROM durability_log_leases
WHERE consumer = ?
AND (state = 'pending'
OR (state = 'active' AND expires_at <= ?))
ORDER BY first_offset ASC
LIMIT 1
)
RETURNING *` ,
batchId,
expiresAt,
consumer,
now
)
. toArray ()[ 0 ]
);
/** Ranges still held, so parallelism can be bounded per consumer. */
const countHeld = ( sql : SqlStorage , consumer : string , now : number ) : number =>
countRowSchema. parse (
sql
. exec (
`SELECT COUNT(*) AS count FROM durability_log_leases
WHERE consumer = ?
AND (state = 'pending' OR (state = 'active' AND expires_at > ?))` ,
consumer,
now
)
. toArray ()[ 0 ]
).count;
/** Settles one range, failing when the batch was fenced or already settled. */
const settleLease = (
sql : SqlStorage ,
batchId : string ,
state : LogLeaseState ,
expiresAt : number
) : boolean =>
sql
. exec (
`UPDATE durability_log_leases
SET state = ?, expires_at = ?
WHERE batch_id = ? AND state = 'active'
RETURNING batch_id` ,
state,
expiresAt,
batchId
)
. toArray (). length > 0 ;
/**
* Ranges from `from` in offset order, so a commit can advance over the
* unbroken run of settled ones and no further.
*/
const leasesFrom = (
sql : SqlStorage ,
consumer : string ,
from : number ,
limit : number
) : LogLeaseRow [] =>
logLeaseRowSchema. array (). parse (
sql
. exec (
`SELECT * FROM durability_log_leases
WHERE consumer = ? AND last_offset >= ?
ORDER BY first_offset ASC
LIMIT ?` ,
consumer,
from,
limit
)
. toArray ()
);
const deleteLeases = ( sql : SqlStorage , batchIds : readonly string []) : void => {
if (batchIds. length === 0 ) {
return ;
}
sql. exec (
`DELETE FROM durability_log_leases
WHERE batch_id IN (${ batchIds . map (() => '?' ). join ( ', ' ) })` ,
... batchIds
);
};
export type LogAppendInput < Body = unknown > = {
body : Body ;
/**
* Appending the same key twice returns the first offset instead of a second
* record. Deduplication lasts only while the original record is retained.
*/
deduplicationKey ?: string ;
};
export type LogRecord < Body = unknown > = {
offset : number ;
deduplicationKey : string ;
body : Body ;
appendedAt : number ;
};
export type LogPage < Body = unknown > = {
records : LogRecord < Body >[];
/** Offset to read from next; commit this to acknowledge the page. */
nextOffset : number ;
/** Records appended beyond this page. */
lag : number ;
};
export type LogReadOptions = {
from : number ;
limit ?: number ;
};
/** One contiguous run of records leaving the object, oldest first. */
export type LogSegment < Body = unknown > = {
firstOffset : number ;
lastOffset : number ;
records : LogRecord < Body >[];
};
/**
* Somewhere records live once they leave the object, usually R2. `write`
* returns a locator the log stores, and `read` hands the segment back so a
* lagging consumer can keep reading flushed offsets. Returning undefined from
* `read` means the segment is gone for good, which surfaces as truncation.
*/
export type LogColdStorage < Body = unknown > = {
write ( segment : LogSegment < Body >) : Promise < string >;
read ( locator : string ) : Promise < LogRecord < Body >[] | undefined >;
};
/** A claimed range of offsets, held until acked, nacked, or expired. */
export type LogLease < Body = unknown > = {
/** Identifies this delivery. A redelivery of the same range gets a new one. */
batchId : string ;
consumer : string ;
records : LogRecord < Body >[];
firstOffset : number ;
lastOffset : number ;
/** One-based delivery attempt for this range. */
attempt : number ;
expiresAt : number ;
};
export type LogLeaseOptions = {
/** Ranges one consumer may hold at once. Defaults to 1. */
maxParallelism ?: number ;
/** How long a claim is held before another worker may take it. */
leaseMs ?: number ;
/**
* Attempts before a range is skipped instead of redelivered forever. Only
* honoured together with `onPoison`: without somewhere to send the records,
* blocking the consumer beats silently dropping them.
*/
maxAttempts ?: number ;
onPoison ?: ( lease : LogLease ) => Promise < void >;
};
export type LogRetention = {
maxAgeMs ?: number ;
maxRecords ?: number ;
/**
* Payload bytes held in the object. Reaching it flushes the oldest records
* to cold storage, or deletes them when no cold storage is configured.
*/
maxBytes ?: number ;
};
export type DurabilityLogConfig < Body = unknown > = SchedulerAttachment & {
/** Without retention the log grows until the object's storage limit. */
retention ?: LogRetention ;
cold ?: LogColdStorage < Body >;
/** Enables `lease`/`ack`/`nack`; plain `read`/`commit` needs no leases. */
leases ?: LogLeaseOptions ;
routing ?: RoutingLoad ;
maxBatchSize ?: number ;
};
/**
* A single object holds a hot window, not an unbounded log, so the byte
* budget is capped well below the platform's per-object limit.
*/
export const maxLogBytes = 1_073_741_824 ;
/**
* A composable DO capability: an append-only record log addressed by offset,
* read by pulling consumers that own their cursors. Share a scheduler with
* operations, alarms, and fanout on the same object.
*
* Unlike fanout, records are retained rather than deleted on acknowledgement,
* so a consumer can rewind or a new one can start from the beginning. Records
* leave only through `trim`, and a consumer that falls behind retention reads a
* `LogTruncatedError` rather than silently skipping records.
*/
export class DurabilityLog < Body = unknown > {
readonly #engine : Engine ;
readonly #routing : RoutingLoad ;
readonly #retention : LogRetention | undefined ;
readonly #cold : LogColdStorage < Body > | undefined ;
readonly #leases : LogLeaseOptions | undefined ;
#maxParallelism = 1 ;
#leaseMs = 30_000 ;
readonly #maxBatchSize : number ;
readonly #encoder = new TextEncoder ();
constructor ( config : DurabilityLogConfig < Body >) {
this .#maxBatchSize = config.maxBatchSize ?? 100 ;
assertPositiveInteger ( 'maxBatchSize' , this .#maxBatchSize);
if (config.retention?.maxAgeMs !== undefined ) {
assertPositiveInteger ( 'retention.maxAgeMs' , config.retention.maxAgeMs);
}
if (config.retention?.maxRecords !== undefined ) {
assertPositiveInteger (
'retention.maxRecords' ,
config.retention.maxRecords
);
}
if (config.retention?.maxBytes !== undefined ) {
assertPositiveInteger ( 'retention.maxBytes' , config.retention.maxBytes);
if (config.retention.maxBytes > maxLogBytes) {
throw new RangeError (
`retention.maxBytes must not exceed ${ maxLogBytes } bytes`
);
}
}
this .#engine = engineFor (config);
this .#routing = config.routing ?? new RoutingLoad ();
this .#retention = config.retention;
this .#cold = config.cold;
this .#leases = config.leases;
if ( this .#leases) {
this .#maxParallelism = this .#leases.maxParallelism ?? 1 ;
assertPositiveInteger ( 'leases.maxParallelism' , this .#maxParallelism);
this .#leaseMs = this .#leases.leaseMs ?? 30_000 ;
assertPositiveInteger ( 'leases.leaseMs' , this .#leaseMs);
if ( this .#leases.maxAttempts !== undefined ) {
assertPositiveInteger ( 'leases.maxAttempts' , this .#leases.maxAttempts);
}
this .#engine. migrateSchema ( 'logLeases' , durabilityLogLeaseMigrations);
}
this .#engine. migrateSchema ( 'log' , durabilityLogMigrations);
}
static migrate (
context : Pick < DurableObjectState , 'storage' >,
target ?: string | null
) {
return migrateCapability (
context.storage,
'log' ,
durabilityLogMigrations,
target
);
}
/**
* Appends the batch atomically. Definite input rejections return
* success:false; storage and commit uncertainty throws, so a caller never
* reads a transport failure as a definite rejection.
*/
async append (
records : LogAppendInput < Body > | LogAppendInput < Body >[]
) : Promise < EnqueueResult < number []>> {
const finish = this .#routing. begin ( 'inbound' );
try {
const inputs = Array. isArray (records) ? records : [records];
let rows : Array <{ dedup_key : string ; payload : string ; bytes : number }>;
try {
if (inputs. length === 0 ) {
throw new LogAppendError ( 'Append requires at least one record' );
}
if (inputs. length > this .#maxBatchSize) {
throw new LogAppendError (
`Append of ${ inputs . length } records exceeds maxBatchSize ${ this . #maxBatchSize }`
);
}
rows = inputs. map (( input ) => {
if (input.deduplicationKey === '' ) {
throw new LogAppendError (
'Append deduplication keys must be non-empty'
);
}
const payload = serialize (input.body);
return {
dedup_key: input.deduplicationKey ?? crypto. randomUUID (),
payload,
bytes: this .#encoder. encode (payload).byteLength,
};
});
} catch (error) {
if ( ! (error instanceof LogAppendError )) {
throw error;
}
return {
success: false ,
error: serializeError (error),
load: await this . load (),
};
}
const appendedAt = Date. now ();
const offsets = this .#engine. transactionSync (( sql ) =>
appendRecords (sql, rows, appendedAt)
);
return { success: true , value: offsets, load: await this . load () };
} finally {
finish ();
}
}
/**
* Reads forward from an offset. Offsets already flushed are rehydrated from
* cold storage, so a lagging consumer keeps reading instead of failing; only
* offsets below everything readable raise `LogTruncatedError`.
*/
async read ( options : LogReadOptions ) : Promise < LogPage < Body >> {
const limit = options.limit ?? this .#maxBatchSize;
assertPositiveInteger ( 'limit' , limit);
if ( ! Number. isInteger (options.from) || options.from < 0 ) {
throw new LogAppendError ( 'Read offset must be a non-negative integer' );
}
const bounds = readBounds ( this .#engine.sql);
if (options.from < bounds.oldestOffset) {
throw new LogTruncatedError (options.from, bounds.oldestOffset);
}
const capped = Math. min (limit, this .#maxBatchSize);
const records =
options.from < bounds.hotOffset
? await this . #rehydrate (options.from, capped, bounds)
: readRecords ( this .#engine.sql, options.from, capped). map (( row ) =>
this . #record (row)
);
const nextOffset =
records. length === 0
? Math. max (options.from, bounds.nextOffset)
: records[records. length - 1 ] ! .offset + 1 ;
return {
records,
nextOffset,
lag: Math. max ( 0 , bounds.nextOffset - nextOffset),
};
}
/**
* Cold reads cost a round trip to external storage and open input gates, so
* they serve only consumers that fell behind the hot window.
*/
async # rehydrate (
from : number ,
limit : number ,
bounds : LogBounds
) : Promise < LogRecord < Body >[]> {
const segment = findSegment ( this .#engine.sql, from);
const records = segment
? await this .#cold?. read (segment.locator)
: undefined ;
if ( ! records) {
// The index outlived the data, so the offset is unreadable after all.
throw new LogTruncatedError (from, Math. max (from + 1 , bounds.hotOffset));
}
return records
. filter (( record ) => record.offset >= from)
. sort (( left , right ) => left.offset - right.offset)
. slice ( 0 , limit);
}
# record ( row : LogRecordRow ) : LogRecord < Body > {
return {
offset: row.offset,
deduplicationKey: row.dedup_key,
body: deserialize (row.payload) as Body ,
appendedAt: row.appended_at,
};
}
/** The retained window. `oldestOffset` equals `nextOffset` when empty. */
async bounds () : Promise < LogBounds > {
return readBounds ( this .#engine.sql);
}
/** Undefined until the consumer commits, so callers choose their own start. */
async cursor ( consumer : string ) : Promise < number | undefined > {
return readCursor ( this .#engine.sql, consumer);
}
/** Records progress. Regressions are ignored, so a late commit cannot rewind. */
async commit ( consumer : string , offset : number ) : Promise < void > {
if ( ! Number. isInteger (offset) || offset < 0 ) {
throw new LogAppendError ( 'Commit offset must be a non-negative integer' );
}
commitCursor ( this .#engine.sql, consumer, offset, Date. now ());
}
async cursors () : Promise < Record < string , number >> {
return Object. fromEntries (
readCursors ( this .#engine.sql). map (( row ) => [row.consumer, row.offset])
);
}
/**
* Enforces retention on the oldest records: with cold storage they are
* flushed and stay readable through rehydration, without it they are
* deleted. A cold write failure propagates with the records still in the
* object, so configured storage cannot be skipped.
*
* Retention ignores cursors, as a log should: a consumer slower than
* everything readable learns through `LogTruncatedError`.
*/
async trim () : Promise < number > {
if ( ! this .#retention) {
return 0 ;
}
const { maxAgeMs , maxRecords , maxBytes } = this .#retention;
const cutoff = maxAgeMs === undefined ? undefined : Date. now () - maxAgeMs;
let removed = 0 ;
for (;;) {
const oldest = listOldest ( this .#engine.sql, this .#maxBatchSize);
if (oldest. length === 0 ) {
return removed;
}
const excess =
maxRecords === undefined
? 0
: Math. max ( 0 , countRecords ( this .#engine.sql) - maxRecords);
const heldBytes =
maxBytes === undefined ? 0 : totalBytes ( this .#engine.sql);
let overBytes = Math. max ( 0 , heldBytes - (maxBytes ?? 0 ));
const expiring = takeWhile (oldest, ( row , index ) => {
const reclaiming = overBytes > 0 ;
overBytes -= row.bytes;
return (
index < excess ||
reclaiming ||
(cutoff !== undefined && row.appended_at < cutoff)
);
});
if (expiring. length === 0 ) {
return removed;
}
const first = expiring[ 0 ] ! ;
const last = expiring[expiring. length - 1 ] ! ;
if ( this .#cold) {
const records = expiring. map (( row ) => this . #record (row));
// eslint-disable-next-line no-await-in-loop
const locator = await this .#cold. write ({
firstOffset: first.offset,
lastOffset: last.offset,
records,
});
if ( typeof locator !== 'string' || locator. length === 0 ) {
throw new TypeError ( 'Cold storage must return a non-empty locator' );
}
// Indexed before deletion: a crash in between leaves the segment
// written and readable, never records that point nowhere.
insertSegment ( this .#engine.sql, {
first_offset: first.offset,
last_offset: last.offset,
locator,
bytes: expiring. reduce (( total , row ) => total + row.bytes, 0 ),
flushed_at: Date. now (),
});
}
const deleted = this .#engine. transactionSync (( sql ) =>
trimThrough (sql, last.offset + 1 , expiring. length )
);
removed += deleted;
// Records are flushed before deletion, so stopping on partial progress
// keeps the next pass from writing the remainder a second time.
if (deleted < expiring. length ) {
return removed;
}
}
}
/**
* Forgets flushed segments below an offset, matching whatever lifecycle rule
* expires them externally. Reads below the new floor then truncate.
*/
async forgetColdBefore ( offset : number ) : Promise < number > {
if ( ! Number. isInteger (offset) || offset < 0 ) {
throw new LogAppendError ( 'Offset must be a non-negative integer' );
}
return this .#engine.sql
. exec (
`DELETE FROM durability_log_segments
WHERE last_offset < ?
RETURNING first_offset` ,
offset
)
. toArray (). length ;
}
/**
* Claims the next unleased range for a consumer, or redelivers one whose
* holder nacked or let its lease expire. Returns undefined when nothing is
* available, either because the consumer is at `maxParallelism` or because
* the log has no unleased records.
*
* Two workers sharing a consumer name never receive the same offsets: the
* range is recorded durably before the records are returned, and a
* redelivery rewrites the batch id so the previous holder can no longer
* settle it.
*/
async lease (
consumer : string ,
limit ?: number
) : Promise < LogLease < Body > | undefined > {
const leases = this . #requireLeases ();
const size = Math. min (limit ?? this .#maxBatchSize, this .#maxBatchSize);
assertPositiveInteger ( 'limit' , size);
assertConsumer (consumer);
const now = Date. now ();
const sql = this .#engine.sql;
const reclaimed = claimExpired (
sql,
consumer,
now,
crypto. randomUUID (),
now + this .#leaseMs
);
if (reclaimed) {
const { oldestOffset } = readBounds (sql);
if (reclaimed.first_offset < oldestOffset) {
this . #dropTrimmed (consumer, reclaimed, oldestOffset);
throw new LogTruncatedError (reclaimed.first_offset, oldestOffset);
}
const poisoned =
leases.maxAttempts !== undefined &&
leases.onPoison !== undefined &&
reclaimed.attempt > leases.maxAttempts;
const lease = {
batchId: reclaimed.batch_id,
consumer,
records: await this . #between (
reclaimed.first_offset,
reclaimed.last_offset
),
firstOffset: reclaimed.first_offset,
lastOffset: reclaimed.last_offset,
attempt: reclaimed.attempt,
expiresAt: reclaimed.expires_at,
};
if ( ! poisoned) {
return lease;
}
// Terminal intent is durable before the callback runs, so a failing
// handler cannot resurrect a range that already exhausted its attempts.
// A skipped range counts as settled, so the cursor moves past it as for an ack.
this .#engine. transactionSync (() => {
this . #settle (reclaimed.batch_id, 'skipped' );
this . #advance (consumer);
});
try {
await leases. onPoison ! (lease as LogLease );
} catch (error) {
reportFailure (
'durability.log.poison_handler.failed' ,
{ consumer },
error
);
}
return undefined ;
}
if ( countHeld (sql, consumer, now) >= this .#maxParallelism) {
return undefined ;
}
const bounds = readBounds (sql);
const committed = readCursor (sql, consumer);
const allocated = readAllocation (sql, consumer);
const from = Math. max (bounds.oldestOffset, committed ?? 0 , allocated ?? 0 );
const records = await this . #readFrom (from, size, bounds);
if (records. length === 0 ) {
return undefined ;
}
const first = records[ 0 ] ! .offset;
const last = records[records. length - 1 ] ! .offset;
const batchId = crypto. randomUUID ();
this .#engine. transactionSync (( transaction ) => {
insertLease (transaction, {
batch_id: batchId,
consumer,
first_offset: first,
last_offset: last,
state: 'active' ,
attempt: 1 ,
expires_at: now + this .#leaseMs,
created_at: now,
});
setAllocation (transaction, consumer, last + 1 );
});
return {
batchId,
consumer,
records,
firstOffset: first,
lastOffset: last,
attempt: 1 ,
expiresAt: now + this .#leaseMs,
};
}
/**
* Accepts a leased range and advances the cursor over every settled range
* that forms an unbroken run from it, so parallel consumers commit without
* skipping offsets another worker is still holding.
*/
async ack ( consumer : string , batchId : string ) : Promise < void > {
this . #requireLeases ();
this .#engine. transactionSync (() => {
this . #settle (batchId, 'acked' );
this . #advance (consumer);
});
}
/** Returns a leased range for redelivery under a fresh batch id. */
async nack ( consumer : string , batchId : string ) : Promise < void > {
this . #requireLeases ();
this . #settle (batchId, 'pending' );
}
/** In-flight and settled-but-uncommitted ranges, for operators and tests. */
async leases ( consumer : string ) : Promise < LogLeaseRow []> {
this . #requireLeases ();
return logLeaseRowSchema. array (). parse (
this .#engine.sql
. exec (
`SELECT * FROM durability_log_leases
WHERE consumer = ?
ORDER BY first_offset ASC` ,
consumer
)
. toArray ()
);
}
# settle ( batchId : string , state : 'acked' | 'pending' | 'skipped' ) : void {
// Pending ranges are immediately claimable, so their expiry is in the past.
const settled = settleLease (
this .#engine.sql,
batchId,
state,
state === 'pending' ? 0 : Date. now ()
);
if (settled) {
return ;
}
// Either the batch never existed, was fenced by a redelivery, or was
// already settled; all three are the caller holding a stale handle.
throw new LogLeaseError (batchId);
}
/**
* Retention trimmed some or all of a range before it was redelivered. Those
* offsets are gone for every consumer, so they leave the range: a range
* wholly below the window is deleted, one straddling it keeps its retained
* tail and is pending again. The failed claim is not counted as a
* delivery attempt.
*/
# dropTrimmed (
consumer : string ,
lease : LogLeaseRow ,
oldestOffset : number
) : void {
this .#engine. transactionSync (( sql ) => {
if (lease.last_offset < oldestOffset) {
deleteLeases (sql, [lease.batch_id]);
} else {
sql. exec (
`UPDATE durability_log_leases
SET first_offset = ?, state = 'pending', attempt = attempt - 1,
expires_at = 0
WHERE batch_id = ?` ,
oldestOffset,
lease.batch_id
);
}
this . #advance (consumer);
});
}
/** Commits the unbroken run of settled ranges starting at the cursor. */
# advance ( consumer : string ) : void {
const sql = this .#engine.sql;
// Offsets below the retained window are gone for everyone; a cursor
// stranded there could never meet the ranges leased after them.
const from = Math. max (
readCursor (sql, consumer) ?? 0 ,
readBounds (sql).oldestOffset
);
let cursor = from;
const settled : string [] = [];
// Ranges settled while an earlier one was outstanding wait behind it; when
// it settles the cursor must pass all of them, however many pages that is.
for ( let page = true ; page; ) {
const candidates = leasesFrom (sql, consumer, cursor, this .#maxBatchSize);
page = candidates. length === this .#maxBatchSize;
for ( const row of candidates) {
const contiguous = row.first_offset <= cursor;
const done = row.state === 'acked' || row.state === 'skipped' ;
if ( ! contiguous || ! done) {
page = false ;
break ;
}
cursor = Math. max (cursor, row.last_offset + 1 );
settled. push (row.batch_id);
}
}
if (settled. length === 0 ) {
return ;
}
commitCursor (sql, consumer, cursor, Date. now ());
deleteLeases (sql, settled);
}
/** Reads a stored range, rehydrating it when retention already flushed it. */
async # between ( first : number , last : number ) : Promise < LogRecord < Body >[]> {
const bounds = readBounds ( this .#engine.sql);
// One read stops at the end of a cold segment, and a range redelivered
// after a flush can span several segments and the hot window.
const records : LogRecord < Body >[] = [];
let from = first;
while (from <= last) {
// eslint-disable-next-line no-await-in-loop
const page = await this . #readFrom (from, last - from + 1 , bounds);
const inRange = page. filter (( record ) => record.offset <= last);
if (inRange. length === 0 ) {
break ;
}
records. push ( ... inRange);
from = inRange[inRange. length - 1 ] ! .offset + 1 ;
}
return records;
}
async # readFrom (
from : number ,
limit : number ,
bounds : LogBounds
) : Promise < LogRecord < Body >[]> {
if (from < bounds.hotOffset && from < bounds.nextOffset) {
return this . #rehydrate (from, limit, bounds);
}
return readRecords ( this .#engine.sql, from, limit). map (( row ) =>
this . #record (row)
);
}
# requireLeases () : LogLeaseOptions {
if ( ! this .#leases) {
throw new TypeError ( 'Leases require the leases option' );
}
return this .#leases;
}
/**
* Backlog is the furthest-behind committed cursor, so routing can weigh
* partitions by consumer lag. Without cursors there is no backlog to report.
*/
async load () : Promise < LoadSnapshot > {
const bounds = readBounds ( this .#engine.sql);
const slowest = readCursors ( this .#engine.sql). reduce < number | undefined >(
( lowest , row ) =>
lowest === undefined ? row.offset : Math. min (lowest, row.offset),
undefined
);
return this .#routing. snapshot (
slowest === undefined ? 0 : Math. max ( 0 , bounds.nextOffset - slowest)
);
}
}
traceMethods ( DurabilityLog . prototype , 'DurabilityLog' );
const assertConsumer = ( consumer : string ) : void => {
if ( typeof consumer !== 'string' || consumer. length === 0 ) {
throw new LogAppendError ( 'Consumer must be a non-empty string' );
}
};
const takeWhile = < T >(
items : readonly T [],
predicate : ( item : T , index : number ) => boolean
) : T [] => {
const taken : T [] = [];
for ( const [ index , item ] of items. entries ()) {
if ( ! predicate (item, index)) {
break ;
}
taken. push (item);
}
return taken;
}; import { z } from 'zod' ;
import { traceFunction, traceMethods } from './dev.js' ;
import {
DurableAlarmTimeoutError,
DurableAttemptsExhaustedError,
isErrorInstance,
NonRetryableError,
} from './errors.js' ;
import {
createLifecycleHooks,
type AlarmLifecycleEntity,
type DurabilityLifecycleEvent,
type LifecycleHook,
type LifecycleHooks,
} from './lifecycle.js' ;
import {
durabilityNamedAlarmMigrations,
durabilityMemoMigrations,
migrateCapability,
type DurabilityMigrationResult,
} from './migrations.js' ;
import {
alarmMethodOptionsSchema,
createPolicyResolver,
type AlarmMethodPolicy,
type DurabilityAlarmMethodOptions,
type ExecutionPolicyOptions,
type PolicyResolver,
} from './policy.js' ;
import {
engineFor,
type ActiveExecution,
type Engine,
type SchedulerAttachment,
} from './scheduler.js' ;
import {
createMemoize,
deleteMemos,
deleteMemosOfDroppedTable,
memoizedKeys,
type DurableMemoize,
} from './memoize.js' ;
import { deleteAttempt, listCreated, type DurableTable } from './storage.js' ;
import {
registerTrackedSource,
type UntypedRecord,
type UntypedSource,
} from './tracked-source.js' ;
const alarmRowSchema = z. object ({
name: z. string (),
generation_id: z. string (),
status: z. enum ([ 'pending' , 'failed' ]),
scheduled_at: z. number (). int (). nonnegative (),
next_attempt_at: z. number (). int (). nonnegative (),
attempt: z. number (). int (). nonnegative (),
last_error: z. string (). nullable (),
last_error_name: z. string (). nullable (),
created_at: z. number (). int (). nonnegative (),
});
type AlarmRow = z . infer < typeof alarmRowSchema>;
const alarmsTable : DurableTable = { name: 'durability_alarms' , key: 'name' };
/** Information passed to a named alarm handler for one execution attempt. */
export type DurableAlarmInfo = {
/** Configured name of the logical alarm. */
name : string ;
/** Timestamp originally passed to the named alarm method. */
scheduledTime : number ;
/**
* One-based attempt ordinal for this scheduled occurrence, durably recorded
* before the handler runs. It is retry and logging context, not a record of
* what the handler did: never gate once-only work on it (such as
* `attempt === 1`). Key such work on durable state keyed by `idempotencyKey`
* instead. See the README identity glossary.
*/
attempt : number ;
/** Whether this scheduled occurrence has run before. */
isRetry : boolean ;
/** Number of previous attempts for this scheduled occurrence. */
retryCount : number ;
/**
* Stable key shared by every attempt of this scheduled occurrence. This is
* the occurrence's logical identity: pass it to downstream services for
* deduplication rather than relying on `attempt`.
*/
idempotencyKey : string ;
/** Aborts when the attempt exceeds its configured timeout. */
signal : AbortSignal ;
/** Persists a callback's result by key so a retry of this occurrence reuses it. */
memoize : DurableMemoize ;
/** Invocation information for the shared physical Durable Object alarm. */
platform : AlarmInvocationInfo | undefined ;
};
/** A handler for one named logical alarm. */
export type DurableAlarmHandler = (
info : DurableAlarmInfo
) => unknown | Promise < unknown >;
const alarmHandlerSchema = z. custom < DurableAlarmHandler >(
( value ) => typeof value === 'function'
);
/** Policy configuration for named alarms. */
export type DurabilityAlarmsOptions < Names extends string = string > =
ExecutionPolicyOptions & {
/** Execution policy overrides keyed by alarm name. */
methods ?: Partial < Record < NoInfer < Names >, DurabilityAlarmMethodOptions >>;
/** Receives best-effort, non-durable lifecycle metrics events. */
onLifecycleEvent ?: LifecycleHook ;
};
/**
* Constructor configuration: the alarm handlers, their policies, and either a
* shared `scheduler` or the Durable Object `context` (plus scheduler options)
* to create a private one.
*/
export type DurabilityAlarmsConfig < Names extends string = string > =
DurabilityAlarmsOptions < Names > &
SchedulerAttachment & {
/** Alarm handlers keyed by the scheduling method names they become. */
handlers : Record < Names , DurableAlarmHandler >;
};
/** A lifecycle event for one of this instance's named alarms. */
export type DurabilityAlarmEvent =
| Extract < DurabilityLifecycleEvent , { entityKind : 'named_alarm' }>
| Extract < DurabilityLifecycleEvent , { type : 'purged' }>;
/** Schedules (or replaces) the named alarm's pending occurrence. */
export type DurableNamedAlarm = (( scheduledTime : number ) => Promise < void >) & {
/**
* Experimental. Deletes the alarm's pending occurrence and reconciles the
* physical alarm, then aborts a running attempt of it. That attempt can no
* longer retry when it settles, but calling the scheduling method again,
* including from that handler, schedules a new occurrence. Resolves whether
* a pending occurrence was deleted.
*/
cancel : () => Promise < boolean >;
};
const readAlarm = ( sql : SqlStorage , name : string ) : AlarmRow | undefined =>
alarmRowSchema
. optional ()
. parse (
sql
. exec ( 'SELECT * FROM durability_alarms WHERE name = ?' , name)
. toArray ()[ 0 ]
);
const alarmRecord = ( sql : SqlStorage , row : AlarmRow ) : UntypedRecord => ({
id: row.name,
name: row.name,
generation: row.generation_id,
createdAt: row.created_at,
state:
row.status === 'pending'
? {
status: 'pending' ,
attempt: row.attempt,
nextAttemptAt: row.next_attempt_at,
lastError: row.last_error,
memoized: memoizedKeys (sql, alarmsTable, row.name, row.generation_id),
}
: {
status: 'failed' ,
attempt: row.attempt,
error: {
name: row.last_error_name ?? 'Error' ,
message: row.last_error ?? 'Durable alarm failed' ,
},
},
});
const alarmEntity = ( row : AlarmRow ) : AlarmLifecycleEntity => ({
entityKind: 'named_alarm' ,
alarm: row.name,
id: row.name,
generation: row.generation_id,
});
class DurabilityAlarmsCore < Names extends string > {
readonly #engine : Engine ;
readonly #handlers : Map < string , DurableAlarmHandler >;
readonly #policy : PolicyResolver < AlarmMethodPolicy >;
readonly #lifecycle : LifecycleHooks ;
readonly #emit : LifecycleHooks [ 'emit' ];
readonly #active = new Map < string , ActiveExecution >();
constructor ( config : DurabilityAlarmsConfig < Names >) {
this .#engine = engineFor (config);
this .#handlers = new Map (
Object. entries < DurableAlarmHandler >(config.handlers). map (
([ name , handler ]) => [name, alarmHandlerSchema. parse (handler)]
)
);
this .#policy = createPolicyResolver (
config,
config.methods ?? {},
alarmMethodOptionsSchema
);
this .#lifecycle = createLifecycleHooks (
( hook ) => this .#engine. emitter (hook),
config.onLifecycleEvent
);
this .#emit = this .#lifecycle.emit;
const reserved = new Set (
Object. getOwnPropertyNames ( DurabilityAlarmsCore . prototype )
);
for ( const name of this .#handlers. keys ()) {
if (reserved. has (name)) {
throw new Error ( `"${ name }" is reserved by DurabilityAlarms` );
}
const method = Object. assign (
traceFunction ( `DurabilityAlarms.${ name }` , ( scheduledTime : number ) =>
this . #schedule (name, scheduledTime)
),
{
cancel: traceFunction ( `DurabilityAlarms.${ name }.cancel` , () =>
this . #cancel (name)
),
}
);
Object. defineProperty ( this , name, {
value: method,
enumerable: true ,
});
}
this .#engine. migrateSchema ( 'namedAlarms' , durabilityNamedAlarmMigrations);
this .#engine. migrateSchema ( 'memos' , durabilityMemoMigrations);
this .#engine. register ({
table: alarmsTable,
runDue : ( now , info ) => this . #runDue (now, info),
});
registerTrackedSource ( this , this . #trackedSource ());
}
/** Applies or reverts the `durability_alarms` schema; see { @link migrateCapability } . */
static migrate (
context : Pick < DurableObjectState , 'storage' >,
target ?: string | null
) : DurabilityMigrationResult {
const result = migrateCapability (
context.storage,
'namedAlarms' ,
durabilityNamedAlarmMigrations,
target
);
deleteMemosOfDroppedTable (context.storage.sql, alarmsTable);
return result;
}
/** Runs due named alarms and re-arms the physical alarm. */
alarm ( info ?: AlarmInvocationInfo ) : Promise < void > {
return this .#engine. alarm (info);
}
/**
* Attaches a best-effort lifecycle hook alongside `onLifecycleEvent` and
* returns a function that detaches it.
*/
lifecycle (
hook : ( event : DurabilityAlarmEvent ) => void | Promise < void >
) : () => void {
return this .#lifecycle. add (hook as LifecycleHook );
}
/**
* Destructively removes named alarm records created strictly before the
* timestamp, regardless of status, and returns how many were removed.
*/
async purgeBefore ( before : number ) : Promise < number > {
const count = await this .#engine. purge (
alarmsTable,
before,
this .#active,
'Durable alarm was purged'
);
this . #emit ({
type: 'purged' ,
entityKind: 'named_alarm' ,
timestamp: Date. now (),
before,
count,
});
return count;
}
/** Success deletes an occurrence, so only pending and failed ones are listed. */
# trackedSource () : UntypedSource {
const sql = this .#engine.sql;
return {
get : async ( name ) => {
const row = readAlarm (sql, name);
return row && alarmRecord (sql, row);
},
list : async ({ limit , cursor , status , name }) => {
const page = listCreated (sql, alarmsTable, 'name' , {
limit,
after: cursor,
status,
name,
});
const records = alarmRowSchema
. array ()
. parse (page.rows)
. map (( row ) => alarmRecord (sql, row));
return page.next === undefined
? { records }
: { records, cursor: page.next };
},
watch : ( name , listener ) =>
this .#engine. watch (alarmsTable, name, ( completed ) => {
if ( ! completed) {
listener ();
return ;
}
listener ({
id: name,
name,
generation: completed.generation_id,
createdAt: completed.created_at,
state: { status: 'completed' , result: undefined },
});
}),
};
}
async # runDue (
now : number ,
info : AlarmInvocationInfo | undefined
) : Promise < void > {
const due = alarmRowSchema. array (). parse (
this .#engine.sql
. exec (
`SELECT * FROM durability_alarms
WHERE status = 'pending' AND next_attempt_at <= ?
ORDER BY next_attempt_at ASC
LIMIT 100` ,
now
)
. toArray ()
);
// With liveTimers a running occurrence re-arms itself when it settles, so
// skipping it keeps a long handler from blocking a concurrent alarm pass.
const pending = this .#engine.liveTimers
? due. filter (( row ) => ! this .#active. has (row.name))
: due;
const failures : unknown [] = [];
await this .#engine. runConcurrent (pending, async ( row ) => {
try {
await this . #execute (row, info);
} catch (error) {
failures. push (error);
}
});
if (failures. length > 0 ) {
throw failures[ 0 ];
}
}
async # schedule ( name : string , scheduledTime : number ) : Promise < void > {
if ( ! Number. isSafeInteger (scheduledTime) || scheduledTime < 0 ) {
throw new RangeError ( 'scheduledTime must be a non-negative safe integer' );
}
const generation = crypto. randomUUID ();
const createdAt = Date. now ();
await this .#engine. transaction (( sql ) => {
sql. exec (
`INSERT INTO durability_alarms
(name, generation_id, status, scheduled_at, next_attempt_at,
attempt, last_error, last_error_name, created_at)
VALUES (?, ?, 'pending', ?, ?, 0, NULL, NULL, ?)
ON CONFLICT(name) DO UPDATE SET
generation_id = excluded.generation_id,
status = excluded.status,
scheduled_at = excluded.scheduled_at,
next_attempt_at = excluded.next_attempt_at,
attempt = excluded.attempt,
last_error = excluded.last_error,
last_error_name = excluded.last_error_name,
created_at = excluded.created_at` ,
name,
generation,
scheduledTime,
scheduledTime,
createdAt
);
// Memoized results belong to the replaced occurrence, never to the new one.
deleteMemos (sql, alarmsTable, name);
});
// A waiter on the replaced occurrence resolves now instead of at its timeout.
this .#engine. notify (alarmsTable, name);
this . #emit ({
type: 'scheduled' ,
entityKind: 'named_alarm' ,
alarm: name,
id: name,
generation,
timestamp: createdAt,
attempt: 0 ,
scheduledTime,
});
}
async # cancel ( name : string ) : Promise < boolean > {
const cancelled = await this .#engine. transaction (( sql ) => {
const removed =
sql
. exec (
`DELETE FROM durability_alarms
WHERE name = ? AND status = 'pending'
RETURNING name` ,
name
)
. toArray (). length > 0 ;
if (removed) {
deleteMemos (sql, alarmsTable, name);
}
return removed;
});
if ( ! cancelled) {
return false ;
}
// Every settlement write is conditional on the deleted row, so a running
// attempt can no longer retry; abort it as purging does.
this .#active
. get (name)
?.controller?. abort ( new Error ( `Durable alarm "${ name }" was cancelled` ));
this .#engine. notify (alarmsTable, name);
return true ;
}
/** One name never has more than one running handler in this instance. */
# execute (
row : AlarmRow ,
platform : AlarmInvocationInfo | undefined
) : Promise < void > {
const running = this .#active. get (row.name);
if (running) {
return running.settled;
}
const policy = this . #policy (row.name);
const retryTimeouts = policy.method?.retryTimeouts ?? false ;
return this .#engine. startExecution ({
table: alarmsTable,
key: row.name,
row,
label: `named alarm "${ row . name }"` ,
entity: alarmEntity (row),
active: this .#active,
policy,
emit: this .#emit,
exhaustedError : () =>
new DurableAttemptsExhaustedError (
'named alarm' ,
row.name,
policy.maxAttempts
),
timeoutError : () =>
new DurableAlarmTimeoutError (row.name, policy.attemptTimeoutMs),
invoke : ( attempt , signal ) => {
const handler = this .#handlers. get (row.name);
if ( ! handler) {
throw new NonRetryableError (
`No named alarm handler registered for "${ row . name }"`
);
}
return handler ({
name: row.name,
scheduledTime: row.scheduled_at,
attempt,
isRetry: attempt > 1 ,
retryCount: attempt - 1 ,
idempotencyKey: `durability-alarm:v1:${ row . generation_id }` ,
signal,
memoize: createMemoize ( this .#engine.sql, alarmsTable, {
key: row.name,
generation: row.generation_id,
attempt,
}),
platform,
});
},
complete : ( _result , attempt ) => {
const timestamp = Date. now ();
const removed = deleteAttempt ( this .#engine.sql, alarmsTable, {
key: row.name,
generation: row.generation_id,
attempt,
});
return removed ? timestamp : undefined ;
},
isTerminal : ( error ) =>
isErrorInstance (error, DurableAlarmTimeoutError) && ! retryTimeouts,
});
}
}
/**
* Named logical alarms sharing one Durable Object's physical alarm.
*
* Every handler becomes a typed scheduling method on the instance. Scheduling
* a name again replaces its pending occurrence; each occurrence has a stable
* `idempotencyKey` across retries.
*
* @example
* ```ts
* class Subscription extends DurableObject<Env> {
* private readonly alarms = new DurabilityAlarms({
* context: this.ctx,
* handlers: {
* renew: async ({ idempotencyKey, signal }) => renew(idempotencyKey, signal),
* },
* });
*
* scheduleRenewal(at: number) {
* return this.alarms.renew(at);
* }
*
* alarm(info?: AlarmInvocationInfo) {
* return this.alarms.alarm(info);
* }
* }
* ```
*/
export type DurabilityAlarms < Names extends string > =
DurabilityAlarmsCore < Names > & Record < Names , DurableNamedAlarm >;
/** Any `DurabilityAlarms` instance, without the index signature of `DurabilityAlarms<string>`. */
export type AnyDurabilityAlarms = DurabilityAlarmsCore < string >;
export interface DurabilityAlarmsConstructor {
new < const Names extends string >(
config : DurabilityAlarmsConfig < Names >
) : DurabilityAlarms < Names >;
migrate : typeof DurabilityAlarmsCore.migrate;
}
traceMethods ( DurabilityAlarmsCore . prototype , 'DurabilityAlarms' );
// See Durability: members derived from the handler names cannot be declared on
// the class, so the constructor is typed separately.
export const DurabilityAlarms =
DurabilityAlarmsCore as unknown as DurabilityAlarmsConstructor ; 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;
};