@nodii/telemetry 0.13.0 → 0.14.0
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/dist/worker/checkpoint-store.d.ts +66 -0
- package/dist/worker/checkpoint-store.d.ts.map +1 -0
- package/dist/worker/checkpoint-store.js +131 -0
- package/dist/worker/checkpoint-store.js.map +1 -0
- package/dist/worker/dead-letter.d.ts +81 -0
- package/dist/worker/dead-letter.d.ts.map +1 -0
- package/dist/worker/dead-letter.js +165 -0
- package/dist/worker/dead-letter.js.map +1 -0
- package/dist/worker/dedupe-store.d.ts +46 -0
- package/dist/worker/dedupe-store.d.ts.map +1 -0
- package/dist/worker/dedupe-store.js +113 -0
- package/dist/worker/dedupe-store.js.map +1 -0
- package/dist/worker/index.d.ts +23 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +46 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/observability-hooks.d.ts +99 -0
- package/dist/worker/observability-hooks.d.ts.map +1 -0
- package/dist/worker/observability-hooks.js +189 -0
- package/dist/worker/observability-hooks.js.map +1 -0
- package/dist/worker/outbox-dispatcher.d.ts +106 -0
- package/dist/worker/outbox-dispatcher.d.ts.map +1 -0
- package/dist/worker/outbox-dispatcher.js +285 -0
- package/dist/worker/outbox-dispatcher.js.map +1 -0
- package/dist/worker/replication-worker.d.ts +162 -0
- package/dist/worker/replication-worker.d.ts.map +1 -0
- package/dist/worker/replication-worker.js +513 -0
- package/dist/worker/replication-worker.js.map +1 -0
- package/dist/worker/signal-drain.d.ts +83 -0
- package/dist/worker/signal-drain.d.ts.map +1 -0
- package/dist/worker/signal-drain.js +131 -0
- package/dist/worker/signal-drain.js.map +1 -0
- package/dist/worker/single-active-lease.d.ts +180 -0
- package/dist/worker/single-active-lease.d.ts.map +1 -0
- package/dist/worker/single-active-lease.js +394 -0
- package/dist/worker/single-active-lease.js.map +1 -0
- package/dist/worker/streams-worker.d.ts +192 -0
- package/dist/worker/streams-worker.d.ts.map +1 -0
- package/dist/worker/streams-worker.js +488 -0
- package/dist/worker/streams-worker.js.map +1 -0
- package/dist/worker/sweeper-worker.d.ts +107 -0
- package/dist/worker/sweeper-worker.d.ts.map +1 -0
- package/dist/worker/sweeper-worker.js +245 -0
- package/dist/worker/sweeper-worker.js.map +1 -0
- package/dist/worker/tx.d.ts +39 -0
- package/dist/worker/tx.d.ts.map +1 -0
- package/dist/worker/tx.js +51 -0
- package/dist/worker/tx.js.map +1 -0
- package/dist/worker/worker-handle.d.ts +94 -0
- package/dist/worker/worker-handle.d.ts.map +1 -0
- package/dist/worker/worker-handle.js +15 -0
- package/dist/worker/worker-handle.js.map +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// D402 — SingleActiveLease: the canonical single-active worker lease primitive
|
|
2
|
+
// of the @nodii/telemetry/worker substrate.
|
|
3
|
+
//
|
|
4
|
+
// This UNIFIES the two divergent hand-rolled leases that exist today:
|
|
5
|
+
// - ts/outbox-dispatcher/src/lease.ts (SET-NX-EX, renew @ ttl/2,
|
|
6
|
+
// key `lock:outbox:<service>`)
|
|
7
|
+
// - ts/replica-consumer/src/worker/lease.ts (renew @ ttl/3,
|
|
8
|
+
// key `nodii:replica-consumer:lease:<svc>:<stream>`)
|
|
9
|
+
// NEITHER carries a fencing token — that is the durability gap D402 closes.
|
|
10
|
+
//
|
|
11
|
+
// D402 (LOCKED) pins the lease terms:
|
|
12
|
+
// - Acquire : Redis `SET key owner NX EX ttl`; canonical key
|
|
13
|
+
// `nodii:lease:<service>:<scope>`.
|
|
14
|
+
// - Renew : Lua CAS at ttl/2 (renew only if WE still own it).
|
|
15
|
+
// - Release : Lua CAS-DEL (release only if WE still own it).
|
|
16
|
+
// - Fencing : the lease carries `(ownerId, fencingToken)`; the token is
|
|
17
|
+
// MONOTONIC and validated AT THE SIDE-EFFECTING MOMENT (not only
|
|
18
|
+
// at acquire/dequeue) so a lease-lost worker's stale write cannot
|
|
19
|
+
// land.
|
|
20
|
+
// - SIGTERM-safe heartbeat.
|
|
21
|
+
//
|
|
22
|
+
// ── Authored design decisions (D402 leaves the encoding / Lua / validate-API to
|
|
23
|
+
// the implementer; everything below is the chosen, justified shape) ─────────
|
|
24
|
+
//
|
|
25
|
+
// • Token generation — a Redis `INCR` on a sibling counter key
|
|
26
|
+
// `nodii:lease:<service>:<scope>:fence`, captured at acquire time. This is the
|
|
27
|
+
// ONLY way to obtain a token that is monotonic *across acquisitions and across
|
|
28
|
+
// processes* — a per-process counter is not globally ordered, so a stale
|
|
29
|
+
// holder on host A could mint a token that collides with the live holder on
|
|
30
|
+
// host B. The counter key is persistent (never expires) so the value survives
|
|
31
|
+
// lease gaps: a later acquirer ALWAYS receives a strictly greater token than
|
|
32
|
+
// any prior holder. Monotonicity needs only strictly-increasing (gaps from
|
|
33
|
+
// failed acquires are harmless), which INCR guarantees.
|
|
34
|
+
//
|
|
35
|
+
// • Value encoding — the lease key stores `"<ownerId>:<fencingToken>"`, NOT the
|
|
36
|
+
// bare ownerId. This lets every guarded operation (renew / release /
|
|
37
|
+
// validateFence) CAS on the *full* `(owner, token)` identity in a single
|
|
38
|
+
// atomic GET-equality check, which is exactly what detects a stale holder
|
|
39
|
+
// whose token no longer matches the key's current value.
|
|
40
|
+
//
|
|
41
|
+
// • Acquire atomicity — INCR-then-SET-NX runs inside one Lua script so the
|
|
42
|
+
// token capture and the lock take are atomic and no successor can interleave.
|
|
43
|
+
//
|
|
44
|
+
// • validateFence(token) — the side-effecting guard. It reads the lease key's
|
|
45
|
+
// CURRENT value LIVE from Redis (not a cached local copy — validating "at the
|
|
46
|
+
// side-effecting moment" means against the current truth) and throws
|
|
47
|
+
// `FenceLostError` unless the live value is exactly `"<ourOwnerId>:<token>"`.
|
|
48
|
+
// A worker calls it immediately before any non-idempotent write; if the lease
|
|
49
|
+
// was lost/expired/taken-over since the work was claimed, the write is
|
|
50
|
+
// rejected before it can land.
|
|
51
|
+
import { uuidv7 } from "../uuid.js";
|
|
52
|
+
import { WORKER_METRIC_NAMES } from "./observability-hooks.js";
|
|
53
|
+
/** Atomic acquire: capture the next monotonic fence + take the lock under NX.
|
|
54
|
+
* KEYS[1] = lease key, KEYS[2] = fence counter key.
|
|
55
|
+
* ARGV[1] = ownerId, ARGV[2] = ttl seconds.
|
|
56
|
+
* Returns the captured fence token (a number) on success, or nil if the lock
|
|
57
|
+
* is already held by someone else (no token is consumed in that case). */
|
|
58
|
+
const ACQUIRE_LUA = `
|
|
59
|
+
if redis.call("EXISTS", KEYS[1]) == 1 then
|
|
60
|
+
return false
|
|
61
|
+
end
|
|
62
|
+
local fence = redis.call("INCR", KEYS[2])
|
|
63
|
+
local val = ARGV[1] .. ":" .. tostring(fence)
|
|
64
|
+
redis.call("SET", KEYS[1], val, "EX", tonumber(ARGV[2]))
|
|
65
|
+
return fence
|
|
66
|
+
`;
|
|
67
|
+
/** CAS-renew: EXPIRE only if the key's current value is exactly our
|
|
68
|
+
* `owner:token`. ARGV[1] = `owner:token`, ARGV[2] = ttl seconds. */
|
|
69
|
+
const RENEW_LUA = `
|
|
70
|
+
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
71
|
+
return redis.call("EXPIRE", KEYS[1], ARGV[2])
|
|
72
|
+
end
|
|
73
|
+
return 0
|
|
74
|
+
`;
|
|
75
|
+
/** CAS-release: DEL only if the key's current value is exactly our
|
|
76
|
+
* `owner:token`. ARGV[1] = `owner:token`. */
|
|
77
|
+
const RELEASE_LUA = `
|
|
78
|
+
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
79
|
+
return redis.call("DEL", KEYS[1])
|
|
80
|
+
end
|
|
81
|
+
return 0
|
|
82
|
+
`;
|
|
83
|
+
/** Canonical lease key shape, pinned by D402. */
|
|
84
|
+
export function leaseKey(service, scope) {
|
|
85
|
+
return `nodii:lease:${service}:${scope}`;
|
|
86
|
+
}
|
|
87
|
+
/** Sibling monotonic fence-counter key for a `(service, scope)`. */
|
|
88
|
+
export function fenceKey(service, scope) {
|
|
89
|
+
return `${leaseKey(service, scope)}:fence`;
|
|
90
|
+
}
|
|
91
|
+
/** Encode the lease value as `<ownerId>:<fencingToken>`. */
|
|
92
|
+
export function encodeLeaseValue(ownerId, token) {
|
|
93
|
+
return `${ownerId}:${token}`;
|
|
94
|
+
}
|
|
95
|
+
/** Parse a `<ownerId>:<fencingToken>` lease value. Returns null if malformed.
|
|
96
|
+
* Owner ids are uuids (which contain `-` but no `:`), so the token is the
|
|
97
|
+
* segment after the LAST `:`. */
|
|
98
|
+
export function parseLeaseValue(value) {
|
|
99
|
+
if (value === null)
|
|
100
|
+
return null;
|
|
101
|
+
const idx = value.lastIndexOf(":");
|
|
102
|
+
if (idx <= 0 || idx === value.length - 1)
|
|
103
|
+
return null;
|
|
104
|
+
const ownerId = value.slice(0, idx);
|
|
105
|
+
const token = Number(value.slice(idx + 1));
|
|
106
|
+
if (!Number.isInteger(token))
|
|
107
|
+
return null;
|
|
108
|
+
return { ownerId, token };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Thrown by {@link SingleActiveLease.validateFence} when the lease no longer
|
|
112
|
+
* belongs to the caller under the token the work was claimed with — the lease
|
|
113
|
+
* expired, was released, or was taken over by a later acquirer (whose fence is
|
|
114
|
+
* strictly greater). A worker that catches this MUST NOT perform the guarded
|
|
115
|
+
* side effect; the write is stale and would corrupt single-active ordering.
|
|
116
|
+
*/
|
|
117
|
+
export class FenceLostError extends Error {
|
|
118
|
+
leaseKey;
|
|
119
|
+
/** The token the work was claimed under (now stale). */
|
|
120
|
+
expectedToken;
|
|
121
|
+
/** The lease's current `(owner, token)`, or null if the key is gone. */
|
|
122
|
+
current;
|
|
123
|
+
constructor(args) {
|
|
124
|
+
const cur = args.current
|
|
125
|
+
? `${args.current.ownerId}:${args.current.token}`
|
|
126
|
+
: "<none>";
|
|
127
|
+
super(`fence lost for ${args.leaseKey}: work claimed under token ${args.expectedToken} ` +
|
|
128
|
+
`but lease now holds ${cur} — stale write rejected`);
|
|
129
|
+
this.name = "FenceLostError";
|
|
130
|
+
this.leaseKey = args.leaseKey;
|
|
131
|
+
this.expectedToken = args.expectedToken;
|
|
132
|
+
this.current = args.current;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Thrown by {@link SingleActiveLease.validateFence} when the fence-check READ
|
|
137
|
+
* itself fails — Redis is down / unreachable / timed out, so ownership cannot be
|
|
138
|
+
* DETERMINED. DISTINCT from {@link FenceLostError}: a FenceLostError means the
|
|
139
|
+
* lease verifiably belongs to someone else now (a benign handoff); a
|
|
140
|
+
* FenceCheckUnavailableError means we could not verify ownership at all.
|
|
141
|
+
*
|
|
142
|
+
* Cross-runtime contract (D402): BOTH FAIL CLOSED — the guarded side-effect MUST
|
|
143
|
+
* NOT run. But the event is treated as a TRANSIENT infrastructure failure: the
|
|
144
|
+
* worker does NOT ack it and does NOT dead-letter it; the event stays in the
|
|
145
|
+
* consumer PEL and is reprocessed on the next poll after Redis recovers
|
|
146
|
+
* (at-least-once; side-effect fires exactly once on recovery). Identical
|
|
147
|
+
* behavior in TS, Python, and Go.
|
|
148
|
+
*/
|
|
149
|
+
export class FenceCheckUnavailableError extends Error {
|
|
150
|
+
leaseKey;
|
|
151
|
+
expectedToken;
|
|
152
|
+
/** The underlying Redis read error. */
|
|
153
|
+
cause;
|
|
154
|
+
constructor(args) {
|
|
155
|
+
const causeMsg = args.cause instanceof Error ? args.cause.message : String(args.cause);
|
|
156
|
+
super(`fence check unavailable for ${args.leaseKey} (token ${args.expectedToken}): ` +
|
|
157
|
+
`lease redis read failed: ${causeMsg} — write withheld, event retried`);
|
|
158
|
+
this.name = "FenceCheckUnavailableError";
|
|
159
|
+
this.leaseKey = args.leaseKey;
|
|
160
|
+
this.expectedToken = args.expectedToken;
|
|
161
|
+
this.cause = args.cause;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A single-active lease with a fencing token. One instance per `(service,
|
|
166
|
+
* scope)` may hold the lease at a time; the holder renews at ttl/2 and exposes
|
|
167
|
+
* {@link validateFence} as the side-effecting guard.
|
|
168
|
+
*
|
|
169
|
+
* Lifecycle:
|
|
170
|
+
* const lease = new SingleActiveLease(opts);
|
|
171
|
+
* if (!(await lease.acquire())) return; // someone else holds it; idle
|
|
172
|
+
* // ... do work; before each non-idempotent write:
|
|
173
|
+
* await lease.validateFence(lease.fencingToken!); // throws FenceLostError
|
|
174
|
+
* await lease.release(); // CAS-DEL on graceful stop
|
|
175
|
+
*/
|
|
176
|
+
export class SingleActiveLease {
|
|
177
|
+
service;
|
|
178
|
+
scope;
|
|
179
|
+
ownerId;
|
|
180
|
+
key;
|
|
181
|
+
fenceCounterKey;
|
|
182
|
+
redis;
|
|
183
|
+
ttlMs;
|
|
184
|
+
ttlSeconds;
|
|
185
|
+
onLeaseLost;
|
|
186
|
+
telemetry;
|
|
187
|
+
_acquired = false;
|
|
188
|
+
_lost = false;
|
|
189
|
+
_fencingToken = null;
|
|
190
|
+
renewTimer = null;
|
|
191
|
+
constructor(opts) {
|
|
192
|
+
this.redis = opts.redis;
|
|
193
|
+
this.service = opts.service;
|
|
194
|
+
this.scope = opts.scope;
|
|
195
|
+
this.ownerId = opts.ownerId ?? uuidv7();
|
|
196
|
+
this.ttlMs = opts.ttlMs;
|
|
197
|
+
this.ttlSeconds = Math.max(1, Math.floor(opts.ttlMs / 1000));
|
|
198
|
+
this.onLeaseLost = opts.onLeaseLost;
|
|
199
|
+
this.telemetry = opts.telemetry;
|
|
200
|
+
this.key = leaseKey(opts.service, opts.scope);
|
|
201
|
+
this.fenceCounterKey = fenceKey(opts.service, opts.scope);
|
|
202
|
+
}
|
|
203
|
+
/** Did we currently hold the lease as of the last acquire/renew? */
|
|
204
|
+
get leaseAcquired() {
|
|
205
|
+
return this._acquired && !this._lost;
|
|
206
|
+
}
|
|
207
|
+
/** Did the renewal loop detect we lost the lease? */
|
|
208
|
+
get lost() {
|
|
209
|
+
return this._lost;
|
|
210
|
+
}
|
|
211
|
+
/** The fence token captured at acquire, or null if we never acquired. */
|
|
212
|
+
get fencingToken() {
|
|
213
|
+
return this._fencingToken;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Attempt to acquire. Resolves `true` if we took the lease (and starts the
|
|
217
|
+
* ttl/2 renewal loop), `false` if another instance already holds it. The
|
|
218
|
+
* fence token is captured atomically inside the acquire Lua.
|
|
219
|
+
*/
|
|
220
|
+
async acquire() {
|
|
221
|
+
const result = await this.redis.eval(ACQUIRE_LUA, 2, this.key, this.fenceCounterKey, this.ownerId, String(this.ttlSeconds));
|
|
222
|
+
// Lua `false` → not acquired; a number → our captured fence.
|
|
223
|
+
if (result === false || result === null) {
|
|
224
|
+
this._acquired = false;
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const token = Number(result);
|
|
228
|
+
if (!Number.isInteger(token)) {
|
|
229
|
+
this._acquired = false;
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
this._fencingToken = token;
|
|
233
|
+
this._acquired = true;
|
|
234
|
+
this._lost = false;
|
|
235
|
+
this.startRenewLoop();
|
|
236
|
+
// CANONICAL D402 name + ISOLATED emit (see `emit`): a vocab/metric throw must
|
|
237
|
+
// never influence lease state.
|
|
238
|
+
this.emit("counter", WORKER_METRIC_NAMES.leaseAcquired, {
|
|
239
|
+
service: this.service,
|
|
240
|
+
scope: this.scope,
|
|
241
|
+
});
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Record a lease metric through the injected telemetry seam, ISOLATED so a
|
|
246
|
+
* throw (e.g. the lib's STRICT `recordMetric` raising `MetricVocabularyError`)
|
|
247
|
+
* can NEVER propagate into the lease's renew/CAS control flow and false-mark
|
|
248
|
+
* the lease lost. Mirrors Python's `_emit` try/except. Telemetry is best-effort
|
|
249
|
+
* observability; it must never break the lease.
|
|
250
|
+
*/
|
|
251
|
+
emit(kind, name, attributes) {
|
|
252
|
+
const tel = this.telemetry;
|
|
253
|
+
if (tel === undefined)
|
|
254
|
+
return;
|
|
255
|
+
try {
|
|
256
|
+
tel.recordMetric({ kind, name, value: 1, attributes });
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// Swallow — telemetry must never break the lease (R1: observability is
|
|
260
|
+
// best-effort; a metric vocab/backend error is not a lease failure).
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Start the ttl/2 renewal loop (CAS on `owner:token`). */
|
|
264
|
+
startRenewLoop() {
|
|
265
|
+
const renewMs = Math.max(1_000, Math.floor(this.ttlMs / 2));
|
|
266
|
+
const encoded = encodeLeaseValue(this.ownerId, this._fencingToken ?? 0);
|
|
267
|
+
this.renewTimer = setInterval(() => {
|
|
268
|
+
void this.renewOnce(encoded);
|
|
269
|
+
}, renewMs);
|
|
270
|
+
this.renewTimer.unref?.();
|
|
271
|
+
}
|
|
272
|
+
/** One CAS renew. Marks the lease lost (and stops the loop) on failure. */
|
|
273
|
+
async renewOnce(encoded) {
|
|
274
|
+
try {
|
|
275
|
+
const r = (await this.redis.eval(RENEW_LUA, 1, this.key, encoded, String(this.ttlSeconds)));
|
|
276
|
+
if (Number(r) === 1) {
|
|
277
|
+
// CANONICAL renewed_total, ISOLATED — a metric throw here must NOT reach
|
|
278
|
+
// the outer catch (which would FALSE-mark the lease lost on a HEALTHY
|
|
279
|
+
// renew, tearing down the worker). That is exactly BUG[3].
|
|
280
|
+
this.emit("counter", WORKER_METRIC_NAMES.leaseRenewed, {
|
|
281
|
+
service: this.service,
|
|
282
|
+
scope: this.scope,
|
|
283
|
+
result: "held",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
this.markLost("lost");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Only a GENUINE renew/CAS (redis) failure reaches here — the metric emit
|
|
292
|
+
// is isolated above, so this catch is never tripped by a telemetry throw.
|
|
293
|
+
this.markLost("error");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
markLost(result) {
|
|
297
|
+
if (this._lost)
|
|
298
|
+
return;
|
|
299
|
+
this._lost = true;
|
|
300
|
+
this.stopRenewLoop();
|
|
301
|
+
// CANONICAL lost_total, ISOLATED.
|
|
302
|
+
this.emit("counter", WORKER_METRIC_NAMES.leaseLost, {
|
|
303
|
+
service: this.service,
|
|
304
|
+
scope: this.scope,
|
|
305
|
+
result,
|
|
306
|
+
});
|
|
307
|
+
this.onLeaseLost?.();
|
|
308
|
+
}
|
|
309
|
+
stopRenewLoop() {
|
|
310
|
+
if (this.renewTimer !== null) {
|
|
311
|
+
clearInterval(this.renewTimer);
|
|
312
|
+
this.renewTimer = null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Renew once, on demand (e.g. a manual heartbeat tick). Returns `true` if we
|
|
317
|
+
* still hold the lease. A non-owner / stale call is a CAS no-op → `false`.
|
|
318
|
+
*/
|
|
319
|
+
async renew() {
|
|
320
|
+
if (this._fencingToken === null)
|
|
321
|
+
return false;
|
|
322
|
+
const encoded = encodeLeaseValue(this.ownerId, this._fencingToken);
|
|
323
|
+
try {
|
|
324
|
+
const r = (await this.redis.eval(RENEW_LUA, 1, this.key, encoded, String(this.ttlSeconds)));
|
|
325
|
+
const held = Number(r) === 1;
|
|
326
|
+
if (!held)
|
|
327
|
+
this.markLost("lost");
|
|
328
|
+
return held;
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
this.markLost("error");
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* THE side-effecting guard. Reads the lease key's CURRENT value live from
|
|
337
|
+
* Redis and throws {@link FenceLostError} unless it is exactly
|
|
338
|
+
* `"<ourOwnerId>:<token>"`. Call immediately before any non-idempotent write
|
|
339
|
+
* to prove the lease is still ours under the token the work was claimed with.
|
|
340
|
+
*
|
|
341
|
+
* @param token the fence token the in-flight work was claimed under.
|
|
342
|
+
*/
|
|
343
|
+
async validateFence(token) {
|
|
344
|
+
let raw;
|
|
345
|
+
try {
|
|
346
|
+
raw = await this.redis.get(this.key);
|
|
347
|
+
}
|
|
348
|
+
catch (cause) {
|
|
349
|
+
// A TRANSIENT Redis read failure (down / unreachable / timeout) means we
|
|
350
|
+
// cannot DETERMINE ownership. FAIL CLOSED (withhold the write) but classify
|
|
351
|
+
// it as transient-unavailable — NOT a verified fence-loss. The worker keeps
|
|
352
|
+
// the event in the PEL + retries on recovery (it does NOT dead-letter a
|
|
353
|
+
// non-poison event over a redis blip). Cross-runtime-identical to Go/Python.
|
|
354
|
+
throw new FenceCheckUnavailableError({
|
|
355
|
+
leaseKey: this.key,
|
|
356
|
+
expectedToken: token,
|
|
357
|
+
cause,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const current = parseLeaseValue(raw);
|
|
361
|
+
const ours = current !== null &&
|
|
362
|
+
current.ownerId === this.ownerId &&
|
|
363
|
+
current.token === token;
|
|
364
|
+
if (!ours) {
|
|
365
|
+
// The key is absent or holds a different (owner, token): a VERIFIED
|
|
366
|
+
// fence-loss / lease handoff.
|
|
367
|
+
throw new FenceLostError({
|
|
368
|
+
leaseKey: this.key,
|
|
369
|
+
expectedToken: token,
|
|
370
|
+
current,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Stop the renewal loop and CAS-release the lease (DEL only if we still hold
|
|
376
|
+
* `owner:token` — a stale owner's release never deletes a successor's lease).
|
|
377
|
+
* Best-effort: a release failure is swallowed since the key TTL-expires.
|
|
378
|
+
* SIGTERM-safe — call from a signal handler to hand off fast.
|
|
379
|
+
*/
|
|
380
|
+
async release() {
|
|
381
|
+
this.stopRenewLoop();
|
|
382
|
+
this._acquired = false;
|
|
383
|
+
if (this._fencingToken === null)
|
|
384
|
+
return;
|
|
385
|
+
const encoded = encodeLeaseValue(this.ownerId, this._fencingToken);
|
|
386
|
+
try {
|
|
387
|
+
await this.redis.eval(RELEASE_LUA, 1, this.key, encoded);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
/* swallow — key TTL-expires anyway */
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
//# sourceMappingURL=single-active-lease.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"single-active-lease.js","sourceRoot":"","sources":["../../src/worker/single-active-lease.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { CheckpointStore } from "./checkpoint-store.js";
|
|
2
|
+
import type { DeadLetterSink } from "./dead-letter.js";
|
|
3
|
+
import type { DedupeStore } from "./dedupe-store.js";
|
|
4
|
+
import { ObservabilityHooks } from "./observability-hooks.js";
|
|
5
|
+
import { type SingleActiveLeaseOpts } from "./single-active-lease.js";
|
|
6
|
+
import type { SqlExecutor } from "./tx.js";
|
|
7
|
+
import type { WorkerHandle, WorkerHwm, WorkerStopOptions, WorkerStopResult } from "./worker-handle.js";
|
|
8
|
+
/**
|
|
9
|
+
* Minimal Redis-Streams client surface a StreamsWorker needs. Structurally
|
|
10
|
+
* compatible with `ioredis` (and replica-consumer's `RedisLike`). The consumer
|
|
11
|
+
* injects its own client; this lib has no hard Redis dependency in its source.
|
|
12
|
+
*/
|
|
13
|
+
export interface StreamsRedisLike {
|
|
14
|
+
xgroup(...args: (string | number)[]): Promise<unknown>;
|
|
15
|
+
xreadgroup(...args: (string | number)[]): Promise<unknown>;
|
|
16
|
+
/**
|
|
17
|
+
* XAUTOCLAIM — transfers ownership of PENDING entries idle ≥ minIdleMs from any
|
|
18
|
+
* (possibly DEAD) consumer to the asking consumer, returning `[cursor, entries,
|
|
19
|
+
* deleted]`. This is the CRASH-RECOVERY reaper: a crashed worker's un-ACK'd
|
|
20
|
+
* entries (in its per-consumer PEL) are reclaimed by the single-active
|
|
21
|
+
* successor (a new instance with a different consumer name) — otherwise they
|
|
22
|
+
* are permanently orphaned and the event is lost (the "no-reaper" durability
|
|
23
|
+
* defect D402 eliminates). Structurally compatible with ioredis `xautoclaim`.
|
|
24
|
+
*/
|
|
25
|
+
xautoclaim(...args: (string | number)[]): Promise<unknown>;
|
|
26
|
+
xack(stream: string, group: string, ...ids: string[]): Promise<number>;
|
|
27
|
+
xlen(stream: string): Promise<number>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A consumed stream event handed to the apply handler. The worker parses the
|
|
31
|
+
* `event` field of each XREADGROUP entry as JSON; `event_id` + `topic` drive
|
|
32
|
+
* dedupe. The worker assigns `streamId` (the `<ms>-<seq>` id) for the cursor.
|
|
33
|
+
*/
|
|
34
|
+
export interface StreamEvent {
|
|
35
|
+
/** The dedupe id — `(event_id, topic)` is the composite dedupe key (D198). */
|
|
36
|
+
event_id: string;
|
|
37
|
+
/** The outbox topic. */
|
|
38
|
+
topic: string;
|
|
39
|
+
/** Optional source emission ms for lag measurement. */
|
|
40
|
+
occurred_at_ms?: number;
|
|
41
|
+
/** The rest of the event payload (the projection input). */
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The apply context handed to the injected handler INSIDE the per-event apply
|
|
46
|
+
* tx. Everything here runs on ONE transaction so the projection upsert + dedupe
|
|
47
|
+
* record + cursor advance commit together-or-none.
|
|
48
|
+
*/
|
|
49
|
+
export interface StreamApplyContext {
|
|
50
|
+
/** The apply transaction handle — run the projection upsert on THIS. */
|
|
51
|
+
tx: SqlExecutor;
|
|
52
|
+
/** The event being applied. */
|
|
53
|
+
event: StreamEvent;
|
|
54
|
+
/** The Redis-Streams id of this entry (the cursor position). */
|
|
55
|
+
streamId: string;
|
|
56
|
+
/**
|
|
57
|
+
* THE side-effecting guard — `await ctx.validateFence()` BEFORE any
|
|
58
|
+
* non-idempotent projection write. Re-reads the live lease and throws
|
|
59
|
+
* {@link FenceLostError} if the lease was lost/taken-over since dequeue.
|
|
60
|
+
*/
|
|
61
|
+
validateFence(): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The injected apply handler — the caller's projection upsert. It runs INSIDE
|
|
65
|
+
* the worker's apply tx; the worker handles dedupe.record + checkpoint.advance
|
|
66
|
+
* around it. The handler MUST call `ctx.validateFence()` before its write.
|
|
67
|
+
*/
|
|
68
|
+
export type StreamApplyHandler = (ctx: StreamApplyContext) => Promise<void>;
|
|
69
|
+
export interface StreamsWorkerOpts {
|
|
70
|
+
redis: StreamsRedisLike;
|
|
71
|
+
/** The source stream key (and the consumer-group base). */
|
|
72
|
+
stream: string;
|
|
73
|
+
/** The injected apply handler (the projection upsert). */
|
|
74
|
+
apply: StreamApplyHandler;
|
|
75
|
+
/** Opens a fresh apply transaction; the worker runs dedupe/checkpoint/apply on it. */
|
|
76
|
+
begin: <T>(fn: (tx: SqlExecutor) => Promise<T>) => Promise<T>;
|
|
77
|
+
dedupe: DedupeStore;
|
|
78
|
+
checkpoint: CheckpointStore;
|
|
79
|
+
/** Lease config (service + scope=stream + ttlMs). */
|
|
80
|
+
lease: Omit<SingleActiveLeaseOpts, "onLeaseLost">;
|
|
81
|
+
/** Checkpoint subscription key. Default `streams:<stream>`. */
|
|
82
|
+
subscription?: string;
|
|
83
|
+
/** Injected dead-letter sink (NO default). */
|
|
84
|
+
deadLetter: DeadLetterSink;
|
|
85
|
+
/** Max in-line retries before dead-lettering a poison event. Default 3. */
|
|
86
|
+
retryMaxAttempts?: number;
|
|
87
|
+
/** XREADGROUP COUNT per poll. Default 32. */
|
|
88
|
+
pollBatch?: number;
|
|
89
|
+
/** XREADGROUP BLOCK ms. Default 2_000 (tests pass small). */
|
|
90
|
+
blockMs?: number;
|
|
91
|
+
/**
|
|
92
|
+
* XAUTOCLAIM crash-recovery threshold (ms): a PENDING entry idle at least this
|
|
93
|
+
* long is assumed to belong to a DEAD consumer and is reclaimed by the
|
|
94
|
+
* single-active successor. Default = max(lease ttlMs, 30_000) so a still-alive
|
|
95
|
+
* predecessor's normal in-flight latency never triggers a steal (the fence
|
|
96
|
+
* guard is the ultimate safety net for the rare race).
|
|
97
|
+
*/
|
|
98
|
+
reclaimMinIdleMs?: number;
|
|
99
|
+
hooks?: ObservabilityHooks;
|
|
100
|
+
workerLabel?: string;
|
|
101
|
+
/** Launch the background consume loop on `start()`. Default true; tests pass
|
|
102
|
+
* false to drive `runOnce()` deterministically. */
|
|
103
|
+
autoStart?: boolean;
|
|
104
|
+
}
|
|
105
|
+
/** Consumer-group name — `${service}:${stream}` (canonical per replica-consumer). */
|
|
106
|
+
export declare function streamsConsumerGroup(service: string, stream: string): string;
|
|
107
|
+
/** Parse an ioredis XREADGROUP response into flat (id, event) pairs. */
|
|
108
|
+
export declare function parseStreamEntries(raw: unknown): Array<{
|
|
109
|
+
id: string;
|
|
110
|
+
event: StreamEvent;
|
|
111
|
+
}>;
|
|
112
|
+
/**
|
|
113
|
+
* Parse an ioredis XAUTOCLAIM reply `[cursor, entries, deleted]` into flat
|
|
114
|
+
* (id, event) pairs. `entries` has the same `[[id, [f, v, ...]], ...]` shape as
|
|
115
|
+
* one stream's entries in an XREADGROUP reply, so we wrap it and reuse
|
|
116
|
+
* {@link parseStreamEntries}.
|
|
117
|
+
*/
|
|
118
|
+
export declare function parseAutoClaimEntries(raw: unknown): Array<{
|
|
119
|
+
id: string;
|
|
120
|
+
event: StreamEvent;
|
|
121
|
+
}>;
|
|
122
|
+
/**
|
|
123
|
+
* StreamsWorker — the generic Pattern-2 single-active stream consumer. Composes
|
|
124
|
+
* lease + dedupe + checkpoint + hooks + drain to consume EXACTLY-ONCE.
|
|
125
|
+
*/
|
|
126
|
+
export declare class StreamsWorker implements WorkerHandle {
|
|
127
|
+
private readonly redis;
|
|
128
|
+
private readonly stream;
|
|
129
|
+
private readonly apply;
|
|
130
|
+
private readonly begin;
|
|
131
|
+
private readonly dedupe;
|
|
132
|
+
private readonly checkpoint;
|
|
133
|
+
private readonly lease;
|
|
134
|
+
private readonly subscription;
|
|
135
|
+
private readonly deadLetter;
|
|
136
|
+
private readonly retryMaxAttempts;
|
|
137
|
+
private readonly pollBatch;
|
|
138
|
+
private readonly blockMs;
|
|
139
|
+
private readonly reclaimMinIdleMs;
|
|
140
|
+
private readonly hooks;
|
|
141
|
+
private readonly autoStart;
|
|
142
|
+
private readonly group;
|
|
143
|
+
private readonly consumer;
|
|
144
|
+
private readonly retries;
|
|
145
|
+
private _running;
|
|
146
|
+
private _stopped;
|
|
147
|
+
private _inFlight;
|
|
148
|
+
private _hwm;
|
|
149
|
+
private _lastAppliedAtMs;
|
|
150
|
+
private groupEnsured;
|
|
151
|
+
private loop;
|
|
152
|
+
constructor(opts: StreamsWorkerOpts);
|
|
153
|
+
get running(): boolean;
|
|
154
|
+
get leaseAcquired(): boolean;
|
|
155
|
+
get lag(): number | null;
|
|
156
|
+
get currentHwm(): WorkerHwm;
|
|
157
|
+
/** Idempotently create the consumer group (swallow BUSYGROUP). */
|
|
158
|
+
private ensureGroup;
|
|
159
|
+
start(): Promise<void>;
|
|
160
|
+
private hydrateCursor;
|
|
161
|
+
private runLoop;
|
|
162
|
+
/**
|
|
163
|
+
* Poll one batch + apply each entry. Returns count processed.
|
|
164
|
+
*
|
|
165
|
+
* Two reads per poll: FIRST our OWN pending-entries-list (PEL) at id `0` —
|
|
166
|
+
* entries delivered to this consumer but NOT yet XACK'd (a prior apply failed
|
|
167
|
+
* + NACK'd them); they MUST be retried, and a plain `>` read never re-returns
|
|
168
|
+
* a PEL entry. THEN `>` for brand-new entries. Without the PEL re-read a
|
|
169
|
+
* poison event would be applied exactly once and never retried.
|
|
170
|
+
*/
|
|
171
|
+
private pollOnce;
|
|
172
|
+
/**
|
|
173
|
+
* Apply one entry: dedupe-skip → apply-tx (projection + dedupe.record +
|
|
174
|
+
* checkpoint.advance, fence-guarded) → XACK. On error, retry-then-dead-letter.
|
|
175
|
+
*/
|
|
176
|
+
private applyOne;
|
|
177
|
+
/**
|
|
178
|
+
* XACK an already-committed entry, SWALLOWING a transient ack failure. The
|
|
179
|
+
* XACK is post-commit (or post-dedupe-skip) and NOT part of the apply tx — a
|
|
180
|
+
* failing XACK is a transient redis blip, never a poison event. On failure the
|
|
181
|
+
* entry simply stays un-acked in the PEL and is re-acked next poll once redis
|
|
182
|
+
* recovers (the durable dedupe row makes the redelivery a no-op). This is what
|
|
183
|
+
* Go does (`_, _ = Ack(...)`); a throw here must NEVER reach retry/DLQ.
|
|
184
|
+
*/
|
|
185
|
+
private ackSwallow;
|
|
186
|
+
/** Increment the retry ledger; dead-letter (+XACK) once the budget is spent. */
|
|
187
|
+
private retryOrDeadLetter;
|
|
188
|
+
/** Run exactly one poll batch (test seam / cron tick). */
|
|
189
|
+
runOnce(): Promise<number>;
|
|
190
|
+
stop(opts?: WorkerStopOptions): Promise<WorkerStopResult>;
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=streams-worker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"streams-worker.d.ts","sourceRoot":"","sources":["../../src/worker/streams-worker.ts"],"names":[],"mappings":"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"}
|