type RetryableReceiptError = {
retryAfterMs: number;
};
const MAX_RETRY_JITTER_RATIO = 0.25;
type PendingWrite = {
input: Input;
bytes: number;
signal: AbortSignal | null;
state: 'blocked' | 'queued' | 'active' | 'settled';
aborted: unknown;
onAbort: (() => void) | null;
resolve: (output: Output) => void;
reject: (error: unknown) => void;
};
export type RuntimeReceiptWriterStats = {
active: boolean;
queued: number;
blocked: number;
bufferedBytes: number;
};
export type RuntimeReceiptWriterRetryEvent = {
phase: 'retry' | 'recovered';
attempt: number;
batchId: number;
batchSize: number;
batchBytes: number;
firstInput: Input;
elapsedMs: number;
retryAfterMs: number;
queued: number;
blocked: number;
bufferedBytes: number;
error?: unknown;
};
export class RuntimeReceiptWriterBufferLimitError extends Error {
constructor(
readonly inputBytes: number,
readonly maxBufferedBytes: number,
) {
super(
`Runtime receipt input is ${inputBytes} bytes, exceeding the ` +
`${maxBufferedBytes}-byte writer limit.`,
);
this.name = 'RuntimeReceiptWriterBufferLimitError';
}
}
export class RuntimeReceiptWriterResultCountError extends Error {
constructor(
readonly inputCount: number,
readonly outputCount: number,
) {
super(
`Runtime receipt transport returned ${outputCount} results for ` +
`${inputCount} inputs.`,
);
this.name = 'RuntimeReceiptWriterResultCountError';
}
}
export class RuntimeReceiptWriterClosedError extends Error {
constructor() {
super('Runtime receipt writer is closed.');
this.name = 'RuntimeReceiptWriterClosedError';
}
}
export class RuntimeReceiptWriterRetryDeadlineError extends Error {
constructor(
readonly attempts: number,
readonly elapsedMs: number,
readonly maxRetryElapsedMs: number,
options?: { cause?: unknown },
) {
super(
`Runtime receipt persistence did not recover within ${maxRetryElapsedMs}ms after ${attempts} attempts.`,
options,
);
this.name = 'RuntimeReceiptWriterRetryDeadlineError';
}
}
/**
* Private batching implementation used by a Play Durability Store Adapter.
*
* The writer deliberately exposes no receipt-domain operations. The Adapter
* translates claim, settle, read, and heartbeat calls into commands while this
* Module owns serialization, bounded buffering, and transport redelivery.
*/
export class RuntimeReceiptWriter {
readonly #batchKey: (input: Input) => unknown;
readonly #send: (
inputs: readonly Input[],
options: { signal: AbortSignal },
) => Promise;
readonly #classifyRetryableError: (
error: unknown,
) => RetryableReceiptError | null;
readonly #estimateBytes: (input: Input) => number;
readonly #maxBatchSize: number;
readonly #maxBatchBytes: number;
readonly #targetBatchBytes: number;
readonly #maxBufferedBytes: number;
readonly #maxFlushMs: number;
readonly #maxRetryElapsedMs: number;
readonly #onRetryEvent:
| ((event: RuntimeReceiptWriterRetryEvent) => void)
| null;
readonly #queued: Array> = [];
readonly #blocked: Array> = [];
readonly #closeController = new AbortController();
#bufferedBytes = 0;
#nextBatchId = 0;
#activeBatch: Array> | null = null;
#pump: Promise | null = null;
#flushTimer: ReturnType | null = null;
#closed = false;
#closeReason: unknown = null;
#drainWaiters: Array<{
resolve: () => void;
reject: (error: unknown) => void;
}> = [];
constructor(options: {
batchKey: (input: Input) => unknown;
send: (
inputs: readonly Input[],
options: { signal: AbortSignal },
) => Promise;
classifyRetryableError?: (error: unknown) => RetryableReceiptError | null;
estimateBytes?: (input: Input) => number;
maxBatchSize?: number;
maxBatchBytes?: number;
targetBatchBytes?: number;
maxBufferedBytes?: number;
maxFlushMs?: number;
maxRetryElapsedMs?: number;
onRetryEvent?: (event: RuntimeReceiptWriterRetryEvent) => void;
}) {
this.#batchKey = options.batchKey;
this.#send = options.send;
this.#classifyRetryableError =
options.classifyRetryableError ?? (() => null);
this.#estimateBytes = options.estimateBytes ?? estimateJsonBytes;
this.#maxBatchSize = normalizePositiveInteger(options.maxBatchSize, 100);
this.#maxBufferedBytes = normalizePositiveInteger(
options.maxBufferedBytes,
32 * 1024 * 1024,
);
this.#maxBatchBytes = Math.min(
normalizePositiveInteger(options.maxBatchBytes, this.#maxBufferedBytes),
this.#maxBufferedBytes,
);
this.#targetBatchBytes = Math.min(
normalizePositiveInteger(options.targetBatchBytes, this.#maxBatchBytes),
this.#maxBatchBytes,
);
this.#maxFlushMs = Math.max(0, Math.floor(options.maxFlushMs ?? 5));
this.#maxRetryElapsedMs = normalizePositiveInteger(
options.maxRetryElapsedMs,
2 * 60_000,
);
this.#onRetryEvent = options.onRetryEvent ?? null;
}
write(input: Input, options: { signal?: AbortSignal } = {}): Promise