@cellaflow/sdk 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +62 -0
- package/dist/context.d.ts +71 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +113 -0
- package/dist/context.js.map +1 -0
- package/dist/durable.d.ts +73 -0
- package/dist/durable.d.ts.map +1 -0
- package/dist/durable.js +84 -0
- package/dist/durable.js.map +1 -0
- package/dist/execution.d.ts +96 -0
- package/dist/execution.d.ts.map +1 -0
- package/dist/execution.js +172 -0
- package/dist/execution.js.map +1 -0
- package/dist/idempotency.d.ts +62 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +91 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/lease.d.ts +50 -0
- package/dist/lease.d.ts.map +1 -0
- package/dist/lease.js +117 -0
- package/dist/lease.js.map +1 -0
- package/dist/tool.d.ts +58 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/tool.js +183 -0
- package/dist/tool.js.map +1 -0
- package/package.json +3 -2
- package/src/context.ts +137 -0
- package/src/durable.ts +177 -0
- package/src/execution.ts +224 -0
- package/src/idempotency.ts +134 -0
- package/src/index.ts +18 -0
- package/src/lease.ts +145 -0
- package/src/tool.ts +247 -0
package/src/execution.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { CellaflowClient } from "./client.js";
|
|
3
|
+
import { CacheStatus } from "./cellaflow/v1/idempotency_pb.js";
|
|
4
|
+
import { LeaseHeartbeat } from "./lease.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
7
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
8
|
+
|
|
9
|
+
/** Raised when the requested execution lease cannot be acquired. */
|
|
10
|
+
export class LeaseNotAcquired extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "LeaseNotAcquired";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Raised by `LeaseHandle.check()` once the lease has been lost. */
|
|
18
|
+
export class LeaseLostError extends Error {
|
|
19
|
+
constructor(message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "LeaseLostError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Handle to an active execution lease.
|
|
27
|
+
*
|
|
28
|
+
* Node cannot interrupt a running task the way Python's asyncio can cancel one,
|
|
29
|
+
* so losing a lease here is **cooperative**: the work keeps running until it
|
|
30
|
+
* checks. Two ways to check, and long or irreversible work should use one:
|
|
31
|
+
*
|
|
32
|
+
* - `check()` throws {@link LeaseLostError} once the lease is gone.
|
|
33
|
+
* - `signal` aborts, so it can be handed to `fetch` or any AbortSignal-aware API.
|
|
34
|
+
*/
|
|
35
|
+
export class LeaseHandle {
|
|
36
|
+
readonly fencingToken: number;
|
|
37
|
+
private readonly controller = new AbortController();
|
|
38
|
+
private lostDetail?: string;
|
|
39
|
+
|
|
40
|
+
constructor(fencingToken: number) {
|
|
41
|
+
this.fencingToken = fencingToken;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Aborts when the lease is lost. Pass to `fetch`, streams, or your own loops. */
|
|
45
|
+
get signal(): AbortSignal {
|
|
46
|
+
return this.controller.signal;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get isLost(): boolean {
|
|
50
|
+
return this.controller.signal.aborted;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @internal */
|
|
54
|
+
markLost(detail: string): void {
|
|
55
|
+
if (this.controller.signal.aborted) return;
|
|
56
|
+
this.lostDetail = detail;
|
|
57
|
+
this.controller.abort(new LeaseLostError(`Execution lease was lost: ${detail}`));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Throws if the lease has been lost.
|
|
62
|
+
*
|
|
63
|
+
* Call this before anything irreversible inside a long block. Nothing else
|
|
64
|
+
* stops the work: losing a lease cannot preempt a running function in Node.
|
|
65
|
+
*/
|
|
66
|
+
check(): void {
|
|
67
|
+
if (this.isLost) {
|
|
68
|
+
throw new LeaseLostError(`Execution lease was lost: ${this.lostDetail}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const currentLeaseStore = new AsyncLocalStorage<LeaseHandle>();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns the lease held by the enclosing block.
|
|
77
|
+
*
|
|
78
|
+
* Lets code inside an {@link executionLease} block, or inside a
|
|
79
|
+
* {@link taskLease} function which has no handle to receive, reach the fencing
|
|
80
|
+
* token to pass downstream.
|
|
81
|
+
*/
|
|
82
|
+
export function currentLease(): LeaseHandle {
|
|
83
|
+
const handle = currentLeaseStore.getStore();
|
|
84
|
+
if (!handle) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"No execution lease is active. currentLease() is only valid inside an " +
|
|
87
|
+
"executionLease(...) block or a taskLease(...) function.",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return handle;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ExecutionLeaseOptions {
|
|
94
|
+
/** Identifies this worker. Sent as `agentId`. */
|
|
95
|
+
workerId: string;
|
|
96
|
+
target?: string;
|
|
97
|
+
secure?: boolean;
|
|
98
|
+
ttlMs?: number;
|
|
99
|
+
heartbeatIntervalMs?: number;
|
|
100
|
+
/** Notified when the lease is lost. A notification, not a substitute for checking. */
|
|
101
|
+
onLeaseLost?: (detail: string) => void;
|
|
102
|
+
/**
|
|
103
|
+
* How long to wait for a lease another worker is holding. Defaults to 0, which
|
|
104
|
+
* fails immediately with {@link LeaseNotAcquired}.
|
|
105
|
+
*/
|
|
106
|
+
waitMs?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runs `fn` holding a distributed lock on `key`, renewed by a heartbeat.
|
|
111
|
+
*
|
|
112
|
+
* Only one worker runs the block at a time. If this process dies, the lease
|
|
113
|
+
* stops being renewed and another worker takes it once the TTL elapses, which is
|
|
114
|
+
* the property a plain database lock cannot offer for a holder that hangs
|
|
115
|
+
* without dying.
|
|
116
|
+
*
|
|
117
|
+
* ```ts
|
|
118
|
+
* await executionLease("task:abc-123", { workerId: "worker-1" }, async (lease) => {
|
|
119
|
+
* lease.check();
|
|
120
|
+
* await doTheWork({ signal: lease.signal });
|
|
121
|
+
* });
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* Unlike the Python `async_execution_lease`, losing the lease does **not**
|
|
125
|
+
* interrupt `fn`: Node has no task cancellation. The handle exposes `check()`
|
|
126
|
+
* and `signal` so the work can abort itself, and anything irreversible should
|
|
127
|
+
* check first.
|
|
128
|
+
*/
|
|
129
|
+
export async function executionLease<T>(
|
|
130
|
+
key: string,
|
|
131
|
+
options: ExecutionLeaseOptions,
|
|
132
|
+
fn: (lease: LeaseHandle) => Promise<T>,
|
|
133
|
+
): Promise<T> {
|
|
134
|
+
const {
|
|
135
|
+
workerId,
|
|
136
|
+
target = "localhost:50051",
|
|
137
|
+
secure = false,
|
|
138
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
139
|
+
heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
140
|
+
onLeaseLost,
|
|
141
|
+
waitMs = 0,
|
|
142
|
+
} = options;
|
|
143
|
+
|
|
144
|
+
const client = new CellaflowClient({ target, secure });
|
|
145
|
+
let hb: LeaseHeartbeat | undefined;
|
|
146
|
+
let fencingToken = 0;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const resp = await client.checkIdempotencyCache(workerId, key, waitMs, ttlMs);
|
|
150
|
+
|
|
151
|
+
if (resp.status === CacheStatus.HIT) {
|
|
152
|
+
throw new LeaseNotAcquired(
|
|
153
|
+
`'${key}' is already committed: the operation it names has completed. An ` +
|
|
154
|
+
"execution lease locks work still to be done, so a committed key means " +
|
|
155
|
+
"this work is finished, not that the lock is busy.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (resp.status !== CacheStatus.ACQUIRED) {
|
|
159
|
+
const holder = resp.currentHolderId ? ` (held by ${resp.currentHolderId})` : "";
|
|
160
|
+
throw new LeaseNotAcquired(
|
|
161
|
+
`Could not acquire execution lease '${key}'${holder}. Another worker is ` +
|
|
162
|
+
"running it. Raise waitMs to wait for them, or treat this as the " +
|
|
163
|
+
"signal that the work is already in hand.",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fencingToken = Number(resp.fencingToken ?? 0n);
|
|
168
|
+
const handle = new LeaseHandle(fencingToken);
|
|
169
|
+
|
|
170
|
+
hb = new LeaseHeartbeat({
|
|
171
|
+
client,
|
|
172
|
+
agentId: workerId,
|
|
173
|
+
idempotencyKey: key,
|
|
174
|
+
fencingToken,
|
|
175
|
+
heartbeatIntervalMs: Number(resp.heartbeatIntervalMs ?? BigInt(heartbeatIntervalMs)),
|
|
176
|
+
leaseTtlMs: ttlMs,
|
|
177
|
+
onLeaseLost: (detail) => {
|
|
178
|
+
handle.markLost(detail);
|
|
179
|
+
try {
|
|
180
|
+
onLeaseLost?.(detail);
|
|
181
|
+
} catch {
|
|
182
|
+
// A caller's callback must not mask the loss itself.
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
hb.start();
|
|
187
|
+
|
|
188
|
+
return await currentLeaseStore.run(handle, () => fn(handle));
|
|
189
|
+
} finally {
|
|
190
|
+
await hb?.stop();
|
|
191
|
+
if (fencingToken > 0) {
|
|
192
|
+
await client
|
|
193
|
+
.releaseLease(workerId, key, fencingToken, "BLOCK_EXIT")
|
|
194
|
+
.catch(() => {
|
|
195
|
+
// The lease expires on its own; a failed release is not worth masking
|
|
196
|
+
// whatever the block was doing.
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
client.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Wraps a function so every call runs under an execution lease.
|
|
205
|
+
*
|
|
206
|
+
* The key is derived per call, so a task id argument becomes the lock:
|
|
207
|
+
*
|
|
208
|
+
* ```ts
|
|
209
|
+
* const processOrder = taskLease(
|
|
210
|
+
* async (orderId: string) => { currentLease().check(); await ship(orderId); },
|
|
211
|
+
* { workerId: "worker-1", key: (orderId) => `order:${orderId}` },
|
|
212
|
+
* );
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
export function taskLease<A extends unknown[], R>(
|
|
216
|
+
fn: (...args: A) => Promise<R>,
|
|
217
|
+
options: ExecutionLeaseOptions & { key: string | ((...args: A) => string) },
|
|
218
|
+
): (...args: A) => Promise<R> {
|
|
219
|
+
const { key, ...leaseOptions } = options;
|
|
220
|
+
return async function leasedTask(...args: A): Promise<R> {
|
|
221
|
+
const resolved = typeof key === "function" ? key(...args) : key;
|
|
222
|
+
return executionLease(resolved, leaseOptions, () => fn(...args));
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import canonicalize from "canonicalize";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How widely a derived idempotency key deduplicates.
|
|
6
|
+
*
|
|
7
|
+
* The numeric values match the Python SDK and the engine's enum. They are part
|
|
8
|
+
* of the wire contract, not an implementation detail.
|
|
9
|
+
*/
|
|
10
|
+
export enum IdempotencyScope {
|
|
11
|
+
UNSPECIFIED = 0,
|
|
12
|
+
/** Shared across all agents in the session. The default. */
|
|
13
|
+
SESSION_WIDE = 1,
|
|
14
|
+
/** Isolated to the executing agent. */
|
|
15
|
+
AGENT_PRIVATE = 2,
|
|
16
|
+
/** Isolated to the specific superstep / node. */
|
|
17
|
+
STEP_LOCAL = 3,
|
|
18
|
+
/**
|
|
19
|
+
* Shared across *sessions* within a declared coordination domain.
|
|
20
|
+
*
|
|
21
|
+
* The only scope whose key omits `sessionId`, so agents running different
|
|
22
|
+
* workflows in different sessions deduplicate one shared side effect.
|
|
23
|
+
*/
|
|
24
|
+
SHARED = 4,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hashes inputs with RFC 8785 Canonical JSON and SHA-256, returning the first
|
|
29
|
+
* 16 bytes hex-encoded.
|
|
30
|
+
*
|
|
31
|
+
* Byte-identical to the Python SDK's `_hash_inputs`, which is what allows a
|
|
32
|
+
* TypeScript agent and a Python agent to converge on one key under
|
|
33
|
+
* {@link IdempotencyScope.SHARED}. Verified against Python reference hashes
|
|
34
|
+
* covering unicode, escapes, nested objects, key reordering and exponent-form
|
|
35
|
+
* numbers. Changing the canonicalisation or the digest length silently stops
|
|
36
|
+
* cross-language deduplication, which fails by repeating the side effect rather
|
|
37
|
+
* than by raising.
|
|
38
|
+
*/
|
|
39
|
+
export function hashInputs(args: unknown[], kwargs: Record<string, unknown>): string {
|
|
40
|
+
const canon = canonicalize({ args, kwargs });
|
|
41
|
+
if (canon === undefined) {
|
|
42
|
+
throw new TypeError(
|
|
43
|
+
"Tool arguments could not be canonicalised. Arguments contributing to an " +
|
|
44
|
+
"idempotency key must be JSON-representable: no functions, symbols, " +
|
|
45
|
+
"BigInt, undefined, or circular references.",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return createHash("sha256").update(canon, "utf8").digest("hex").slice(0, 32);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DeriveKeyOptions {
|
|
52
|
+
sessionId: string;
|
|
53
|
+
workflowVersion: string;
|
|
54
|
+
stepSequence: number;
|
|
55
|
+
agentId: string;
|
|
56
|
+
toolName: string;
|
|
57
|
+
scope: IdempotencyScope;
|
|
58
|
+
coordinationId?: string;
|
|
59
|
+
/** Positional arguments the tool was called with. */
|
|
60
|
+
args?: unknown[];
|
|
61
|
+
/** Named arguments, when the tool takes a single options object. */
|
|
62
|
+
kwargs?: Record<string, unknown>;
|
|
63
|
+
/**
|
|
64
|
+
* Restricts the hash to the named keys of `kwargs`.
|
|
65
|
+
*
|
|
66
|
+
* Heterogeneous agents converge on one side effect precisely when they
|
|
67
|
+
* disagree about everything else, so hashing everything they pass is the one
|
|
68
|
+
* thing guaranteed to keep them apart.
|
|
69
|
+
*
|
|
70
|
+
* JavaScript has no named arguments, so a tool using `sharedOn` must take a
|
|
71
|
+
* single options object; the names are read from it.
|
|
72
|
+
*/
|
|
73
|
+
sharedOn?: readonly string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Derives the canonical idempotency key for a step or tool execution. */
|
|
77
|
+
export function deriveIdempotencyKey(opts: DeriveKeyOptions): string {
|
|
78
|
+
const {
|
|
79
|
+
sessionId,
|
|
80
|
+
workflowVersion,
|
|
81
|
+
stepSequence,
|
|
82
|
+
agentId,
|
|
83
|
+
toolName,
|
|
84
|
+
scope,
|
|
85
|
+
coordinationId,
|
|
86
|
+
args = [],
|
|
87
|
+
kwargs = {},
|
|
88
|
+
sharedOn,
|
|
89
|
+
} = opts;
|
|
90
|
+
|
|
91
|
+
let inputsHash: string;
|
|
92
|
+
if (sharedOn !== undefined) {
|
|
93
|
+
const selected: Record<string, unknown> = {};
|
|
94
|
+
for (const k of sharedOn) {
|
|
95
|
+
if (Object.prototype.hasOwnProperty.call(kwargs, k)) selected[k] = kwargs[k];
|
|
96
|
+
}
|
|
97
|
+
inputsHash = hashInputs([], selected);
|
|
98
|
+
} else {
|
|
99
|
+
inputsHash = hashInputs(args, kwargs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (scope === IdempotencyScope.SHARED) {
|
|
103
|
+
// The only scope that omits sessionId, so agents in different sessions
|
|
104
|
+
// converge on one key. It also omits workflowVersion, because the whole
|
|
105
|
+
// point is that *different* workflows share the operation and they will
|
|
106
|
+
// not be on the same version.
|
|
107
|
+
//
|
|
108
|
+
// coordinationId is what keeps this from being too wide. Without it two
|
|
109
|
+
// unrelated callers of sendEmail({to: X}) would deduplicate, suppressing
|
|
110
|
+
// one of them silently. It is required, and the caller must choose it.
|
|
111
|
+
if (!coordinationId) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"IdempotencyScope.SHARED requires a coordinationId naming the work being " +
|
|
114
|
+
"shared: a ticket, task, or tenant id. Pass it when opening the session, " +
|
|
115
|
+
'e.g. durableTools(config, { coordinationId: "ticket-4417" }, fn). It has ' +
|
|
116
|
+
"no default: a shared one would deduplicate unrelated callers that happen " +
|
|
117
|
+
"to make the same call.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return `shared:${coordinationId}:${toolName}:${inputsHash}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let seqPart = "session_wide";
|
|
124
|
+
let agentPart = "session_wide";
|
|
125
|
+
|
|
126
|
+
if (scope === IdempotencyScope.AGENT_PRIVATE) {
|
|
127
|
+
agentPart = agentId;
|
|
128
|
+
} else if (scope === IdempotencyScope.STEP_LOCAL) {
|
|
129
|
+
seqPart = String(stepSequence);
|
|
130
|
+
agentPart = agentId;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return `${sessionId}:${workflowVersion}:${seqPart}:${agentPart}:${toolName}:${inputsHash}`;
|
|
134
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,3 +5,21 @@ export * from "./cellaflow/v1/idempotency_pb.js";
|
|
|
5
5
|
// Note: internal_pb is intentionally NOT re-exported here. It contains
|
|
6
6
|
// engine-internal types (CacheRecord, LeaseRecord, etc.) that are not part
|
|
7
7
|
// of the public SDK surface.
|
|
8
|
+
|
|
9
|
+
// High-level API
|
|
10
|
+
export { IdempotencyScope, deriveIdempotencyKey, hashInputs } from "./idempotency.js";
|
|
11
|
+
export type { DeriveKeyOptions } from "./idempotency.js";
|
|
12
|
+
export { tool, step, DivergentStepError } from "./tool.js";
|
|
13
|
+
export type { ToolOptions } from "./tool.js";
|
|
14
|
+
export { durableTools, toolSessionId } from "./durable.js";
|
|
15
|
+
export type { DurableToolsOptions, DurableSession, ThreadRef } from "./durable.js";
|
|
16
|
+
export {
|
|
17
|
+
executionLease,
|
|
18
|
+
taskLease,
|
|
19
|
+
currentLease,
|
|
20
|
+
LeaseHandle,
|
|
21
|
+
LeaseNotAcquired,
|
|
22
|
+
LeaseLostError,
|
|
23
|
+
} from "./execution.js";
|
|
24
|
+
export type { ExecutionLeaseOptions } from "./execution.js";
|
|
25
|
+
export { WorkflowContext } from "./context.js";
|
package/src/lease.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { CellaflowClient } from "./client.js";
|
|
2
|
+
import { RenewFailureReason } from "./cellaflow/v1/idempotency_pb.js";
|
|
3
|
+
|
|
4
|
+
/** Why the engine refused to renew, in words a caller can act on. */
|
|
5
|
+
function renewFailureDetail(reason: RenewFailureReason | undefined): string {
|
|
6
|
+
switch (reason) {
|
|
7
|
+
case RenewFailureReason.EXPIRED:
|
|
8
|
+
return "the lease had already expired";
|
|
9
|
+
case RenewFailureReason.SUPERSEDED:
|
|
10
|
+
return "another worker took the lease";
|
|
11
|
+
case RenewFailureReason.NOT_FOUND:
|
|
12
|
+
return "the engine has no record of the lease";
|
|
13
|
+
case RenewFailureReason.COMPLETED:
|
|
14
|
+
return "the operation was already committed";
|
|
15
|
+
case RenewFailureReason.MAX_LIFETIME_EXCEEDED:
|
|
16
|
+
return "the lease hit its maximum lifetime and was reclaimed";
|
|
17
|
+
default:
|
|
18
|
+
return "the engine refused renewal";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LeaseHeartbeatOptions {
|
|
23
|
+
client: CellaflowClient;
|
|
24
|
+
agentId: string;
|
|
25
|
+
idempotencyKey: string;
|
|
26
|
+
fencingToken: number;
|
|
27
|
+
/** How often to renew. The engine suggests this on acquisition. */
|
|
28
|
+
heartbeatIntervalMs: number;
|
|
29
|
+
/** How long each renewal extends the lease. Defaults to 3x the interval. */
|
|
30
|
+
extendMs?: number;
|
|
31
|
+
/**
|
|
32
|
+
* How long the lease survives without a confirmed renewal. Loss is declared
|
|
33
|
+
* against this, not against an error count.
|
|
34
|
+
*/
|
|
35
|
+
leaseTtlMs?: number;
|
|
36
|
+
/** Called once when the lease is determined to be lost. */
|
|
37
|
+
onLeaseLost?: (detail: string) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Renews a held lease on an interval until stopped.
|
|
42
|
+
*
|
|
43
|
+
* The engine requires a caller holding a lease to renew it, so this starts as
|
|
44
|
+
* soon as one is acquired and stops in a `finally`.
|
|
45
|
+
*/
|
|
46
|
+
export class LeaseHeartbeat {
|
|
47
|
+
private readonly opts: Required<Omit<LeaseHeartbeatOptions, "onLeaseLost">> &
|
|
48
|
+
Pick<LeaseHeartbeatOptions, "onLeaseLost">;
|
|
49
|
+
private timer?: NodeJS.Timeout;
|
|
50
|
+
private stopped = false;
|
|
51
|
+
private lastConfirmed = Date.now();
|
|
52
|
+
private inFlight?: Promise<void>;
|
|
53
|
+
|
|
54
|
+
/** Set once the lease is known to be lost. Read by `LeaseHandle.check()`. */
|
|
55
|
+
lost = false;
|
|
56
|
+
lostDetail?: string;
|
|
57
|
+
|
|
58
|
+
constructor(options: LeaseHeartbeatOptions) {
|
|
59
|
+
const intervalMs = options.heartbeatIntervalMs;
|
|
60
|
+
this.opts = {
|
|
61
|
+
...options,
|
|
62
|
+
heartbeatIntervalMs: intervalMs,
|
|
63
|
+
extendMs: options.extendMs ?? intervalMs * 3,
|
|
64
|
+
leaseTtlMs: options.leaseTtlMs ?? intervalMs * 3,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
start(): void {
|
|
69
|
+
if (this.timer) return;
|
|
70
|
+
this.timer = setInterval(() => {
|
|
71
|
+
// Never overlap renewals: a slow RPC would otherwise queue them and each
|
|
72
|
+
// would extend from a stale view of the lease.
|
|
73
|
+
if (this.inFlight) return;
|
|
74
|
+
this.inFlight = this.beat().finally(() => {
|
|
75
|
+
this.inFlight = undefined;
|
|
76
|
+
});
|
|
77
|
+
}, this.opts.heartbeatIntervalMs);
|
|
78
|
+
// Do not hold the event loop open on the heartbeat alone.
|
|
79
|
+
this.timer.unref?.();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async stop(): Promise<void> {
|
|
83
|
+
this.stopped = true;
|
|
84
|
+
if (this.timer) {
|
|
85
|
+
clearInterval(this.timer);
|
|
86
|
+
this.timer = undefined;
|
|
87
|
+
}
|
|
88
|
+
// Let an in-flight renewal settle so it cannot outlive the block that owns
|
|
89
|
+
// the lease and renew something the caller has already released.
|
|
90
|
+
if (this.inFlight) await this.inFlight.catch(() => {});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private notifyLost(detail: string): void {
|
|
94
|
+
if (this.lost) return;
|
|
95
|
+
this.lost = true;
|
|
96
|
+
this.lostDetail = detail;
|
|
97
|
+
void this.stop();
|
|
98
|
+
try {
|
|
99
|
+
this.opts.onLeaseLost?.(detail);
|
|
100
|
+
} catch {
|
|
101
|
+
// A caller's callback must not take down the heartbeat.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* True once the lease can no longer be assumed held.
|
|
107
|
+
*
|
|
108
|
+
* A failed renewal is not itself proof of loss: the RPC failing means we could
|
|
109
|
+
* not confirm the lease, while the engine may still hold it until the TTL runs
|
|
110
|
+
* out. Declaring loss on an error count would abort work that still holds a
|
|
111
|
+
* perfectly valid lease, so loss is declared on elapsed time since the last
|
|
112
|
+
* *confirmed* renewal.
|
|
113
|
+
*/
|
|
114
|
+
private ttlExhausted(): boolean {
|
|
115
|
+
return Date.now() - this.lastConfirmed >= this.opts.leaseTtlMs;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async beat(): Promise<void> {
|
|
119
|
+
if (this.stopped || this.lost) return;
|
|
120
|
+
try {
|
|
121
|
+
const resp = await this.opts.client.renewLease(
|
|
122
|
+
this.opts.agentId,
|
|
123
|
+
this.opts.idempotencyKey,
|
|
124
|
+
this.opts.fencingToken,
|
|
125
|
+
this.opts.extendMs,
|
|
126
|
+
// Bound the RPC. Without a deadline a black-holed connection parks the
|
|
127
|
+
// renewal forever and the lease silently ages out while we wait on it.
|
|
128
|
+
Math.max(1, Math.ceil(this.opts.heartbeatIntervalMs / 1000)),
|
|
129
|
+
);
|
|
130
|
+
if (!resp.renewed) {
|
|
131
|
+
this.notifyLost(renewFailureDetail(resp.failureReason));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.lastConfirmed = Date.now();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (this.ttlExhausted()) {
|
|
137
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
138
|
+
this.notifyLost(
|
|
139
|
+
`could not be renewed for ${this.opts.leaseTtlMs}ms (last error: ${msg})`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
// Otherwise: transient. The engine may still be holding it for us.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|