@nodii/telemetry 0.12.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.
Files changed (60) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +5 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/lambda/index.d.ts +77 -0
  5. package/dist/lambda/index.d.ts.map +1 -0
  6. package/dist/lambda/index.js +130 -0
  7. package/dist/lambda/index.js.map +1 -0
  8. package/dist/worker/checkpoint-store.d.ts +66 -0
  9. package/dist/worker/checkpoint-store.d.ts.map +1 -0
  10. package/dist/worker/checkpoint-store.js +131 -0
  11. package/dist/worker/checkpoint-store.js.map +1 -0
  12. package/dist/worker/dead-letter.d.ts +81 -0
  13. package/dist/worker/dead-letter.d.ts.map +1 -0
  14. package/dist/worker/dead-letter.js +165 -0
  15. package/dist/worker/dead-letter.js.map +1 -0
  16. package/dist/worker/dedupe-store.d.ts +46 -0
  17. package/dist/worker/dedupe-store.d.ts.map +1 -0
  18. package/dist/worker/dedupe-store.js +113 -0
  19. package/dist/worker/dedupe-store.js.map +1 -0
  20. package/dist/worker/index.d.ts +23 -0
  21. package/dist/worker/index.d.ts.map +1 -0
  22. package/dist/worker/index.js +46 -0
  23. package/dist/worker/index.js.map +1 -0
  24. package/dist/worker/observability-hooks.d.ts +99 -0
  25. package/dist/worker/observability-hooks.d.ts.map +1 -0
  26. package/dist/worker/observability-hooks.js +189 -0
  27. package/dist/worker/observability-hooks.js.map +1 -0
  28. package/dist/worker/outbox-dispatcher.d.ts +106 -0
  29. package/dist/worker/outbox-dispatcher.d.ts.map +1 -0
  30. package/dist/worker/outbox-dispatcher.js +285 -0
  31. package/dist/worker/outbox-dispatcher.js.map +1 -0
  32. package/dist/worker/replication-worker.d.ts +162 -0
  33. package/dist/worker/replication-worker.d.ts.map +1 -0
  34. package/dist/worker/replication-worker.js +513 -0
  35. package/dist/worker/replication-worker.js.map +1 -0
  36. package/dist/worker/signal-drain.d.ts +83 -0
  37. package/dist/worker/signal-drain.d.ts.map +1 -0
  38. package/dist/worker/signal-drain.js +131 -0
  39. package/dist/worker/signal-drain.js.map +1 -0
  40. package/dist/worker/single-active-lease.d.ts +180 -0
  41. package/dist/worker/single-active-lease.d.ts.map +1 -0
  42. package/dist/worker/single-active-lease.js +394 -0
  43. package/dist/worker/single-active-lease.js.map +1 -0
  44. package/dist/worker/streams-worker.d.ts +192 -0
  45. package/dist/worker/streams-worker.d.ts.map +1 -0
  46. package/dist/worker/streams-worker.js +488 -0
  47. package/dist/worker/streams-worker.js.map +1 -0
  48. package/dist/worker/sweeper-worker.d.ts +107 -0
  49. package/dist/worker/sweeper-worker.d.ts.map +1 -0
  50. package/dist/worker/sweeper-worker.js +245 -0
  51. package/dist/worker/sweeper-worker.js.map +1 -0
  52. package/dist/worker/tx.d.ts +39 -0
  53. package/dist/worker/tx.d.ts.map +1 -0
  54. package/dist/worker/tx.js +51 -0
  55. package/dist/worker/tx.js.map +1 -0
  56. package/dist/worker/worker-handle.d.ts +94 -0
  57. package/dist/worker/worker-handle.d.ts.map +1 -0
  58. package/dist/worker/worker-handle.js +15 -0
  59. package/dist/worker/worker-handle.js.map +1 -0
  60. package/package.json +10 -1
@@ -0,0 +1,162 @@
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 StreamsRedisLike } from "./streams-worker.js";
7
+ import type { SqlExecutor } from "./tx.js";
8
+ import type { WorkerHandle, WorkerHwm, WorkerStopOptions, WorkerStopResult } from "./worker-handle.js";
9
+ /** A replication event off the source stream. Carries the LWW version. */
10
+ export interface ReplicationEvent {
11
+ /** Dedupe id — `(event_id, topic)` is the composite dedupe key (D198). */
12
+ event_id: string;
13
+ /** The outbox topic. */
14
+ topic: string;
15
+ /** The replicated entity's id (the projection key). */
16
+ entity_id: string;
17
+ /** The monotonic source version — drives last-write-wins. */
18
+ entity_version: number | string | bigint;
19
+ /** Optional source emission ms for lag. */
20
+ occurred_at_ms?: number;
21
+ /** The rest of the payload (the projection input). */
22
+ [key: string]: unknown;
23
+ }
24
+ /** The apply context handed to the injected projection upsert INSIDE the tx. */
25
+ export interface ReplicationApplyContext {
26
+ tx: SqlExecutor;
27
+ event: ReplicationEvent;
28
+ streamId: string;
29
+ /** THE fence guard — call BEFORE the projection upsert. */
30
+ validateFence(): Promise<void>;
31
+ }
32
+ /** The injected projection upsert. Runs inside the worker's apply tx. */
33
+ export type ReplicationApplyHandler = (ctx: ReplicationApplyContext) => Promise<void>;
34
+ /** The injected audit emitter — fires AFTER a successful commit (§ 4 audit). */
35
+ export type ReplicationAuditHook = (info: {
36
+ event: ReplicationEvent;
37
+ streamId: string;
38
+ /** True when the event applied; false when LWW-skipped (still consumed). */
39
+ applied: boolean;
40
+ }) => Promise<void> | void;
41
+ /** A snapshot bootstrap source — streams the snapshot rows + the HWM to stitch. */
42
+ export interface SnapshotBootstrap {
43
+ /**
44
+ * Apply the full snapshot into the projection and return the high-water
45
+ * stream id the live consume must stitch from. The worker writes this as the
46
+ * cursor so live consume resumes exactly there (§ 7 stitch). The bootstrap
47
+ * runs its own apply (it owns the projection schema); the worker only records
48
+ * the returned HWM as the durable cursor.
49
+ */
50
+ bootstrap(tx: SqlExecutor): Promise<{
51
+ highWaterStreamId: string;
52
+ }>;
53
+ }
54
+ export interface ReplicationWorkerOpts {
55
+ redis: StreamsRedisLike;
56
+ /** The source replication stream key. */
57
+ sourceStream: string;
58
+ /** The injected projection upsert. */
59
+ apply: ReplicationApplyHandler;
60
+ /**
61
+ * Reads the projection's CURRENT source_entity_version for an entity, for the
62
+ * LWW comparison. Returns null when the entity isn't projected yet (→ apply).
63
+ */
64
+ currentVersion: (tx: SqlExecutor, event: ReplicationEvent) => Promise<bigint | null>;
65
+ /** Opens an apply transaction; the worker runs dedupe/checkpoint/apply on it. */
66
+ begin: <T>(fn: (tx: SqlExecutor) => Promise<T>) => Promise<T>;
67
+ dedupe: DedupeStore;
68
+ checkpoint: CheckpointStore;
69
+ lease: Omit<SingleActiveLeaseOpts, "onLeaseLost">;
70
+ /** Checkpoint subscription key. Default `replication:<sourceStream>`. */
71
+ subscription?: string;
72
+ deadLetter: DeadLetterSink;
73
+ /** Optional snapshot bootstrap (run once if the cursor is absent). */
74
+ snapshot?: SnapshotBootstrap;
75
+ /** Optional audit hook (fires after each successful commit). */
76
+ audit?: ReplicationAuditHook;
77
+ retryMaxAttempts?: number;
78
+ pollBatch?: number;
79
+ blockMs?: number;
80
+ /** XAUTOCLAIM crash-recovery threshold (ms). Default max(lease ttlMs, 30_000). */
81
+ reclaimMinIdleMs?: number;
82
+ hooks?: ObservabilityHooks;
83
+ workerLabel?: string;
84
+ /** Launch the background consume loop on `start()`. Default true; tests pass
85
+ * false to drive `runOnce()` deterministically. */
86
+ autoStart?: boolean;
87
+ }
88
+ /** Consumer-group name — `${service}:${stream}` (canonical per replica-consumer). */
89
+ export declare function replicationConsumerGroup(service: string, stream: string): string;
90
+ /**
91
+ * ReplicationWorker — the Pattern-3 subscriber. Composes lease + dedupe +
92
+ * checkpoint + hooks + drain + snapshot-bootstrap + LWW + audit. EXACTLY-ONCE
93
+ * apply, monotonic projection (no LWW regression), atomic cursor+dedupe+upsert.
94
+ */
95
+ export declare class ReplicationWorker implements WorkerHandle {
96
+ private readonly redis;
97
+ private readonly sourceStream;
98
+ private readonly applyHandler;
99
+ private readonly currentVersion;
100
+ private readonly begin;
101
+ private readonly dedupe;
102
+ private readonly checkpoint;
103
+ private readonly lease;
104
+ private readonly subscription;
105
+ private readonly deadLetter;
106
+ private readonly snapshot?;
107
+ private readonly audit?;
108
+ private readonly retryMaxAttempts;
109
+ private readonly pollBatch;
110
+ private readonly blockMs;
111
+ private readonly reclaimMinIdleMs;
112
+ private readonly hooks;
113
+ private readonly autoStart;
114
+ private readonly group;
115
+ private readonly consumer;
116
+ private readonly retries;
117
+ private _running;
118
+ private _stopped;
119
+ private _inFlight;
120
+ private _bootstrapped;
121
+ private _hwm;
122
+ private _lastAppliedAtMs;
123
+ private groupEnsured;
124
+ private loop;
125
+ constructor(opts: ReplicationWorkerOpts);
126
+ get running(): boolean;
127
+ get leaseAcquired(): boolean;
128
+ get lag(): number | null;
129
+ get currentHwm(): WorkerHwm;
130
+ /** True once the snapshot bootstrap (if any) has run / been determined absent. */
131
+ get bootstrapped(): boolean;
132
+ private ensureGroup;
133
+ start(): Promise<void>;
134
+ /**
135
+ * Snapshot-bootstrap + HWM stitch (§ 7): if the durable cursor is ABSENT (cold
136
+ * start) AND a snapshot source is wired, run the snapshot bootstrap inside ONE
137
+ * tx and write the returned high-water stream id as the cursor — live consume
138
+ * then resumes EXACTLY from there (no gap, no double-apply of the snapshot
139
+ * window). If a cursor already exists we skip the snapshot and resume live.
140
+ */
141
+ maybeBootstrap(): Promise<void>;
142
+ private runLoop;
143
+ private pollOnce;
144
+ /**
145
+ * Apply one replication event: dedupe-skip → LWW check → apply tx (projection
146
+ * + dedupe.record + checkpoint.advance, fence-guarded, ONE tx) → XACK → audit.
147
+ */
148
+ private applyOne;
149
+ /**
150
+ * XACK an already-committed entry, SWALLOWING a transient ack failure. The
151
+ * XACK is post-commit (or post-dedupe-skip) and NOT part of the apply tx — a
152
+ * failing XACK is a transient redis blip, never a poison event. On failure the
153
+ * entry stays un-acked in the PEL and is re-acked next poll once redis
154
+ * recovers (the durable dedupe row makes the redelivery a no-op). Matches Go's
155
+ * `_, _ = Ack(...)`; a throw here must NEVER reach retry/DLQ.
156
+ */
157
+ private ackSwallow;
158
+ private retryOrDeadLetter;
159
+ runOnce(): Promise<number>;
160
+ stop(opts?: WorkerStopOptions): Promise<WorkerStopResult>;
161
+ }
162
+ //# sourceMappingURL=replication-worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replication-worker.d.ts","sourceRoot":"","sources":["../../src/worker/replication-worker.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,513 @@
1
+ // D402 — ReplicationWorker: the Pattern-3 SUBSCRIBER consume side — the
2
+ // canonical `09-replication-doctrine § 4` sequence every replicating service
3
+ // follows.
4
+ //
5
+ // D402 (LOCKED): "ReplicationWorker — Pattern-3, BOTH sides: producer
6
+ // outbox-drain + subscriber consume + snapshot-bootstrap + HWM stitch + dedupe +
7
+ // last-write-wins + audit." This file is the SUBSCRIBER consume side (the
8
+ // producer side is OutboxDispatcher); it GENERALIZES `ts/replica-consumer/src/`
9
+ // (which this lib does NOT edit — replica-consumer gets re-based onto this in
10
+ // Phase 2).
11
+ //
12
+ // ── Composition (which primitives, how) ──────────────────────────────────────
13
+ // - SingleActiveLease — single-active per (service, source_stream); ttl/2
14
+ // renew inside the lease. `validateFence(token)` guards the projection
15
+ // upsert: a lease-lost subscriber mid-apply has its stale write REJECTED.
16
+ // - DedupeStore — `seen(...)` skips an already-applied (event_id, topic);
17
+ // `record(...)` runs INSIDE the apply tx (atomic with projection + cursor).
18
+ // - CheckpointStore — the HWM cursor; `advance(...)` runs in the SAME apply
19
+ // tx. Also stitches snapshot→live: the bootstrap writes the snapshot HWM as
20
+ // the cursor, then live consume resumes from it (§ 7 stitch).
21
+ // - ObservabilityHooks — lease/lag/hwm/depth metrics, canonical names.
22
+ // - SignalDrain — `stop({timeoutMs})` registerable; bounded drain.
23
+ // - DeadLetterSink — INJECTED (no default); a poison event after retries
24
+ // is dead-lettered + XACK'd.
25
+ // - audit hook — an injected `onApplied(...)` audit emitter fires AFTER
26
+ // a successful commit (the § 4 "audit" step).
27
+ //
28
+ // ── LAST-WRITE-WINS (the replication-specific guard) ─────────────────────────
29
+ // Each event carries `entity_version`. The apply is LWW: an event whose
30
+ // `entity_version` is <= the projection's current `source_entity_version` is a
31
+ // STALE replay and MUST NOT regress the projection. The worker compares
32
+ // against the value the injected `currentVersion(...)` returns (the consumer
33
+ // reads its own projection row) and SKIPS the apply (but STILL records dedupe +
34
+ // advances the cursor, so the stale event is consumed-once and never retried).
35
+ // The CheckpointStore's `high_water_entity_version` also tracks the LWW HWM
36
+ // monotonically (GREATEST) as a secondary guard.
37
+ //
38
+ // ── The consume sequence (09-replication § 4) ────────────────────────────────
39
+ // bootstrap (if cursor absent: snapshot → write snapshot HWM cursor) →
40
+ // live consume: XREADGROUP > → for each (id, event):
41
+ // [1] dedupe.seen? → XACK + skip
42
+ // [2] LWW: event.entity_version <= current? → consume-but-skip-apply
43
+ // [3] apply tx: projection upsert (fence-guarded) + dedupe.record +
44
+ // checkpoint.advance(+hwev) — ONE tx, atomic
45
+ // [4] XACK; audit hook; on error retry-then-dead-letter.
46
+ import { ensurePiiRedaction } from "./dead-letter.js";
47
+ import { hwmGaugeValue, ObservabilityHooks } from "./observability-hooks.js";
48
+ import { FenceCheckUnavailableError, FenceLostError, SingleActiveLease, } from "./single-active-lease.js";
49
+ import { parseAutoClaimEntries, parseStreamEntries, } from "./streams-worker.js";
50
+ const DEFAULT_BATCH = 32;
51
+ const DEFAULT_BLOCK_MS = 2_000;
52
+ const DEFAULT_RETRY_MAX = 3;
53
+ const DEFAULT_STOP_TIMEOUT_MS = 30_000;
54
+ const DEFAULT_RECLAIM_MIN_IDLE_MS = 30_000;
55
+ function toBigint(v) {
56
+ return typeof v === "bigint" ? v : BigInt(v);
57
+ }
58
+ /** Consumer-group name — `${service}:${stream}` (canonical per replica-consumer). */
59
+ export function replicationConsumerGroup(service, stream) {
60
+ return `${service}:${stream}`;
61
+ }
62
+ /**
63
+ * ReplicationWorker — the Pattern-3 subscriber. Composes lease + dedupe +
64
+ * checkpoint + hooks + drain + snapshot-bootstrap + LWW + audit. EXACTLY-ONCE
65
+ * apply, monotonic projection (no LWW regression), atomic cursor+dedupe+upsert.
66
+ */
67
+ export class ReplicationWorker {
68
+ redis;
69
+ sourceStream;
70
+ applyHandler;
71
+ currentVersion;
72
+ begin;
73
+ dedupe;
74
+ checkpoint;
75
+ lease;
76
+ subscription;
77
+ deadLetter;
78
+ snapshot;
79
+ audit;
80
+ retryMaxAttempts;
81
+ pollBatch;
82
+ blockMs;
83
+ reclaimMinIdleMs;
84
+ hooks;
85
+ autoStart;
86
+ group;
87
+ consumer;
88
+ retries = new Map();
89
+ _running = false;
90
+ _stopped = false;
91
+ _inFlight = 0;
92
+ _bootstrapped = false;
93
+ _hwm = {
94
+ lastStreamId: null,
95
+ highWaterEntityVersion: null,
96
+ };
97
+ _lastAppliedAtMs = null;
98
+ groupEnsured = false;
99
+ loop = Promise.resolve();
100
+ constructor(opts) {
101
+ this.redis = opts.redis;
102
+ this.sourceStream = opts.sourceStream;
103
+ this.applyHandler = opts.apply;
104
+ this.currentVersion = opts.currentVersion;
105
+ this.begin = opts.begin;
106
+ this.dedupe = opts.dedupe;
107
+ this.checkpoint = opts.checkpoint;
108
+ this.lease = new SingleActiveLease({
109
+ ...opts.lease,
110
+ onLeaseLost: () => this.hooks.leaseLost(),
111
+ });
112
+ this.subscription = opts.subscription ?? `replication:${opts.sourceStream}`;
113
+ this.deadLetter = ensurePiiRedaction(opts.deadLetter);
114
+ this.snapshot = opts.snapshot;
115
+ this.audit = opts.audit;
116
+ this.retryMaxAttempts = opts.retryMaxAttempts ?? DEFAULT_RETRY_MAX;
117
+ this.pollBatch = opts.pollBatch ?? DEFAULT_BATCH;
118
+ this.blockMs = opts.blockMs ?? DEFAULT_BLOCK_MS;
119
+ this.reclaimMinIdleMs =
120
+ opts.reclaimMinIdleMs ??
121
+ Math.max(opts.lease.ttlMs, DEFAULT_RECLAIM_MIN_IDLE_MS);
122
+ this.autoStart = opts.autoStart !== false;
123
+ this.hooks =
124
+ opts.hooks ??
125
+ new ObservabilityHooks({
126
+ attributes: {
127
+ service: opts.lease.service,
128
+ scope: opts.lease.scope,
129
+ worker: opts.workerLabel ?? "replication",
130
+ },
131
+ });
132
+ this.group = replicationConsumerGroup(opts.lease.service, opts.sourceStream);
133
+ this.consumer = `${opts.lease.service}:${this.lease.ownerId}`;
134
+ }
135
+ get running() {
136
+ return this._running;
137
+ }
138
+ get leaseAcquired() {
139
+ return this.lease.leaseAcquired;
140
+ }
141
+ get lag() {
142
+ if (this._lastAppliedAtMs === null)
143
+ return null;
144
+ return Math.max(0, (Date.now() - this._lastAppliedAtMs) / 1000);
145
+ }
146
+ get currentHwm() {
147
+ return this._hwm;
148
+ }
149
+ /** True once the snapshot bootstrap (if any) has run / been determined absent. */
150
+ get bootstrapped() {
151
+ return this._bootstrapped;
152
+ }
153
+ async ensureGroup() {
154
+ if (this.groupEnsured)
155
+ return;
156
+ try {
157
+ await this.redis.xgroup("CREATE", this.sourceStream, this.group, "$", "MKSTREAM");
158
+ }
159
+ catch (err) {
160
+ const msg = err instanceof Error ? err.message : String(err);
161
+ if (!/BUSYGROUP/i.test(msg))
162
+ throw err;
163
+ }
164
+ this.groupEnsured = true;
165
+ }
166
+ async start() {
167
+ if (this._running)
168
+ return;
169
+ this._stopped = false;
170
+ const acquired = await this.lease.acquire();
171
+ if (acquired)
172
+ this.hooks.leaseAcquired();
173
+ // ensureGroup (XGROUP CREATE) is an idempotent no-op when the group exists —
174
+ // safe regardless of lease ownership. The SNAPSHOT BOOTSTRAP (which WRITES the
175
+ // projection + cursor) runs ONLY when we hold the lease: a non-holder Start()
176
+ // must NOT bootstrap, or two cold instances racing into the shared projection
177
+ // would both snapshot (single-active violation). A non-holder idles; the loop
178
+ // re-acquires + bootstraps (lease-guarded) once it wins. BUG-1: release the
179
+ // lease on a boot-step failure after a successful acquire (never orphan it).
180
+ try {
181
+ await this.ensureGroup();
182
+ if (acquired)
183
+ await this.maybeBootstrap();
184
+ }
185
+ catch (err) {
186
+ if (acquired)
187
+ await this.lease.release();
188
+ throw err;
189
+ }
190
+ this._running = true;
191
+ // The background consume loop. A test may set `autoStart:false` to drive
192
+ // `runOnce()` deterministically without the loop racing the explicit ticks.
193
+ if (this.autoStart)
194
+ this.loop = this.runLoop();
195
+ }
196
+ /**
197
+ * Snapshot-bootstrap + HWM stitch (§ 7): if the durable cursor is ABSENT (cold
198
+ * start) AND a snapshot source is wired, run the snapshot bootstrap inside ONE
199
+ * tx and write the returned high-water stream id as the cursor — live consume
200
+ * then resumes EXACTLY from there (no gap, no double-apply of the snapshot
201
+ * window). If a cursor already exists we skip the snapshot and resume live.
202
+ */
203
+ async maybeBootstrap() {
204
+ if (this._bootstrapped)
205
+ return;
206
+ const existing = await this.begin((tx) => this.checkpoint.get(this.subscription, tx));
207
+ if (existing) {
208
+ // Warm start — resume live from the durable cursor.
209
+ this._hwm = {
210
+ lastStreamId: existing.lastStreamId,
211
+ highWaterEntityVersion: existing.highWaterEntityVersion,
212
+ };
213
+ this._bootstrapped = true;
214
+ return;
215
+ }
216
+ const snapshot = this.snapshot;
217
+ if (!snapshot) {
218
+ // No snapshot wired + no cursor → consume live from `>` (group at `$`).
219
+ this._bootstrapped = true;
220
+ return;
221
+ }
222
+ // Cold start WITH a snapshot — bootstrap + stitch the cursor in ONE tx so
223
+ // the snapshot apply + the cursor write commit together-or-none.
224
+ await this.begin(async (tx) => {
225
+ const { highWaterStreamId } = await snapshot.bootstrap(tx);
226
+ await this.checkpoint.advance({ subscription: this.subscription, lastStreamId: highWaterStreamId }, tx);
227
+ });
228
+ this._hwm = {
229
+ lastStreamId: null,
230
+ highWaterEntityVersion: null,
231
+ };
232
+ const cp = await this.begin((tx) => this.checkpoint.get(this.subscription, tx));
233
+ if (cp) {
234
+ this._hwm = {
235
+ lastStreamId: cp.lastStreamId,
236
+ highWaterEntityVersion: cp.highWaterEntityVersion,
237
+ };
238
+ }
239
+ this._bootstrapped = true;
240
+ }
241
+ async runLoop() {
242
+ while (!this._stopped) {
243
+ if (this.lease.lost)
244
+ break;
245
+ try {
246
+ const processed = await this.pollOnce();
247
+ if (processed === 0) {
248
+ await new Promise((r) => setTimeout(r, 0));
249
+ }
250
+ }
251
+ catch (err) {
252
+ // BUG-2 fix: a poll that throws BECAUSE we're shutting down (stop() set
253
+ // `_stopped`) is a CLEAN cancel, not a redis failure — exit quietly.
254
+ // A genuine poll-level error (XREADGROUP rejected — Redis down) while
255
+ // still running is the Pillar-5 infra-failure case: back off, never crash.
256
+ if (this._stopped)
257
+ break;
258
+ this.hooks.recordDepth(-1, { error: "poll_failed" });
259
+ void err;
260
+ await new Promise((r) => setTimeout(r, 200));
261
+ }
262
+ }
263
+ }
264
+ async pollOnce() {
265
+ let reacquiredThisPoll = false;
266
+ if (!this.lease.leaseAcquired) {
267
+ const got = await this.lease.acquire();
268
+ if (got) {
269
+ reacquiredThisPoll = true;
270
+ this.hooks.leaseAcquired();
271
+ // Re-ensure the consumer group on every re-acquire (a Redis failover may
272
+ // have wiped it — a cached `groupEnsured` would let xreadgroup hit
273
+ // NOGROUP forever).
274
+ this.groupEnsured = false;
275
+ // BUG fix (in-loop re-acquire): release the freshly-taken lease if
276
+ // ensureGroup throws — otherwise the renewal loop holds it INDEFINITELY
277
+ // while the next poll hammers a never-created group (NOGROUP loop).
278
+ try {
279
+ await this.ensureGroup();
280
+ }
281
+ catch (err) {
282
+ await this.lease.release();
283
+ throw err;
284
+ }
285
+ }
286
+ else {
287
+ return 0;
288
+ }
289
+ }
290
+ if (!this._bootstrapped) {
291
+ // Same in-loop rule: a bootstrap failure right after THIS poll re-acquired
292
+ // must release the freshly-taken lease (a lease held from a prior poll is
293
+ // left intact — maybeBootstrap won't run then, `_bootstrapped` is true).
294
+ try {
295
+ await this.maybeBootstrap();
296
+ }
297
+ catch (err) {
298
+ if (reacquiredThisPoll)
299
+ await this.lease.release();
300
+ throw err;
301
+ }
302
+ }
303
+ let processed = 0;
304
+ // [reclaim] CRASH-RECOVERY reaper: XAUTOCLAIM transfers PENDING entries idle
305
+ // ≥ reclaimMinIdleMs from a DEAD predecessor to US (the single-active
306
+ // successor) — without it a crashed worker's un-ACK'd entries are
307
+ // PERMANENTLY ORPHANED (silent loss; the "no-reaper" defect D402 eliminates).
308
+ // The fence guard + LWW still protect against a still-alive predecessor.
309
+ const claimedRaw = await this.redis.xautoclaim(this.sourceStream, this.group, this.consumer, this.reclaimMinIdleMs, "0-0", "COUNT", this.pollBatch);
310
+ for (const p of parseAutoClaimEntries(claimedRaw)) {
311
+ if (this._stopped || this.lease.lost)
312
+ break;
313
+ await this.applyOne(p.id, p.event);
314
+ processed += 1;
315
+ }
316
+ // [retry] Re-read our OWN PEL (id `0`) for NACK'd entries — they must be
317
+ // retried, and a plain `>` read never re-returns a PEL entry.
318
+ const pendingRaw = await this.redis.xreadgroup("GROUP", this.group, this.consumer, "COUNT", this.pollBatch, "STREAMS", this.sourceStream, "0");
319
+ for (const p of parseStreamEntries(pendingRaw)) {
320
+ if (this._stopped || this.lease.lost)
321
+ break;
322
+ await this.applyOne(p.id, p.event);
323
+ processed += 1;
324
+ }
325
+ // [new] Read brand-new entries (`>`, blocking).
326
+ const raw = await this.redis.xreadgroup("GROUP", this.group, this.consumer, "COUNT", this.pollBatch, "BLOCK", this.blockMs, "STREAMS", this.sourceStream, ">");
327
+ try {
328
+ const depth = await this.redis.xlen(this.sourceStream);
329
+ this.hooks.recordDepth(depth);
330
+ }
331
+ catch {
332
+ /* depth advisory */
333
+ }
334
+ for (const p of parseStreamEntries(raw)) {
335
+ if (this._stopped || this.lease.lost)
336
+ break;
337
+ await this.applyOne(p.id, p.event);
338
+ processed += 1;
339
+ }
340
+ return processed;
341
+ }
342
+ /**
343
+ * Apply one replication event: dedupe-skip → LWW check → apply tx (projection
344
+ * + dedupe.record + checkpoint.advance, fence-guarded, ONE tx) → XACK → audit.
345
+ */
346
+ async applyOne(streamId, event) {
347
+ if (this.lease.lost)
348
+ return;
349
+ this._inFlight += 1;
350
+ const token = this.lease.fencingToken;
351
+ try {
352
+ // [1] Pre-tx dedupe probe.
353
+ const alreadySeen = await this.begin((tx) => this.dedupe.seen(event.event_id, event.topic, tx));
354
+ if (alreadySeen) {
355
+ // The event already committed — only the XACK is outstanding. A failing
356
+ // XACK is a TRANSIENT ack-failure, NOT a poison event: swallow it (re-acked
357
+ // next poll). NEVER route a committed event to retry/DLQ over an ack blip.
358
+ await this.ackSwallow(streamId);
359
+ this.retries.delete(streamId);
360
+ return;
361
+ }
362
+ const incomingVersion = toBigint(event.entity_version);
363
+ let applied = false;
364
+ // [2]+[3] Apply tx: LWW check + projection + dedupe + cursor — ONE tx.
365
+ await this.begin(async (tx) => {
366
+ // LWW: read the projection's current version IN the tx so the compare
367
+ // is consistent with the upsert.
368
+ const current = await this.currentVersion(tx, event);
369
+ const isStale = current !== null && incomingVersion <= current;
370
+ if (!isStale) {
371
+ // Apply the projection (fence-guarded BEFORE the write).
372
+ const ctx = {
373
+ tx,
374
+ event,
375
+ streamId,
376
+ validateFence: async () => {
377
+ if (token === null) {
378
+ throw new FenceLostError({
379
+ leaseKey: this.lease.key,
380
+ expectedToken: -1,
381
+ current: null,
382
+ });
383
+ }
384
+ await this.lease.validateFence(token);
385
+ },
386
+ };
387
+ await this.applyHandler(ctx);
388
+ applied = true;
389
+ }
390
+ // ALWAYS record dedupe + advance the cursor — even on an LWW-skip the
391
+ // event is consumed-once (it must never be retried) and the cursor must
392
+ // advance past it. The hwev advances monotonically (GREATEST) regardless.
393
+ await this.dedupe.record(event.event_id, event.topic, tx);
394
+ await this.checkpoint.advance({
395
+ subscription: this.subscription,
396
+ lastStreamId: streamId,
397
+ highWaterEntityVersion: incomingVersion,
398
+ }, tx);
399
+ });
400
+ // [4] Commit succeeded → XACK + audit + advance local HWM. The XACK is
401
+ // POST-COMMIT and NOT part of the apply tx: a failing XACK is a
402
+ // transient ack-failure, NOT a poison event — swallow it (re-acked next
403
+ // poll; the dedupe row makes a redelivery a no-op). Do NOT route a
404
+ // successfully-committed event to retry/DLQ over an ack blip.
405
+ await this.ackSwallow(streamId);
406
+ this.retries.delete(streamId);
407
+ const prevHwev = this._hwm.highWaterEntityVersion ?? null;
408
+ this._hwm = {
409
+ lastStreamId: streamId,
410
+ highWaterEntityVersion: prevHwev !== null && prevHwev > incomingVersion
411
+ ? prevHwev
412
+ : incomingVersion,
413
+ };
414
+ this._lastAppliedAtMs = Date.now();
415
+ this.hooks.recordHwm(hwmGaugeValue(streamId));
416
+ if (event.occurred_at_ms !== undefined) {
417
+ this.hooks.recordLag(Math.max(0, (Date.now() - event.occurred_at_ms) / 1000));
418
+ }
419
+ else {
420
+ this.hooks.recordLag(0);
421
+ }
422
+ if (this.audit) {
423
+ try {
424
+ await this.audit({ event, streamId, applied });
425
+ }
426
+ catch {
427
+ // The audit hook is best-effort AFTER commit — a failure must not
428
+ // un-apply the projection (already committed); log-and-continue.
429
+ }
430
+ }
431
+ }
432
+ catch (err) {
433
+ if (err instanceof FenceLostError) {
434
+ this.hooks.leaseLost();
435
+ return;
436
+ }
437
+ // Transient fence-check redis failure: fail closed, leave the event in the
438
+ // PEL (no XACK, no DLQ) to reprocess on recovery — cross-runtime-identical.
439
+ if (err instanceof FenceCheckUnavailableError) {
440
+ this.hooks.recordDepth(-1, { error: "fence_check_unavailable" });
441
+ return;
442
+ }
443
+ await this.retryOrDeadLetter(streamId, event, err);
444
+ }
445
+ finally {
446
+ this._inFlight -= 1;
447
+ }
448
+ }
449
+ /**
450
+ * XACK an already-committed entry, SWALLOWING a transient ack failure. The
451
+ * XACK is post-commit (or post-dedupe-skip) and NOT part of the apply tx — a
452
+ * failing XACK is a transient redis blip, never a poison event. On failure the
453
+ * entry stays un-acked in the PEL and is re-acked next poll once redis
454
+ * recovers (the durable dedupe row makes the redelivery a no-op). Matches Go's
455
+ * `_, _ = Ack(...)`; a throw here must NEVER reach retry/DLQ.
456
+ */
457
+ async ackSwallow(streamId) {
458
+ try {
459
+ await this.redis.xack(this.sourceStream, this.group, streamId);
460
+ }
461
+ catch {
462
+ this.hooks.recordDepth(-1, { error: "ack_failed" });
463
+ }
464
+ }
465
+ async retryOrDeadLetter(streamId, event, err) {
466
+ const attempt = (this.retries.get(streamId) ?? 0) + 1;
467
+ this.retries.set(streamId, attempt);
468
+ if (attempt < this.retryMaxAttempts)
469
+ return; // stays in the PEL, redelivered.
470
+ try {
471
+ await this.deadLetter.deadLetter({
472
+ sourceStream: this.sourceStream,
473
+ topic: event.topic,
474
+ eventId: event.event_id,
475
+ payload: event,
476
+ errorMessage: err instanceof Error ? err.message : String(err),
477
+ retryCount: attempt,
478
+ });
479
+ await this.redis.xack(this.sourceStream, this.group, streamId);
480
+ this.retries.delete(streamId);
481
+ }
482
+ catch {
483
+ // Dead-letter sink failed — leave in the PEL rather than lose it.
484
+ }
485
+ }
486
+ async runOnce() {
487
+ return this.pollOnce();
488
+ }
489
+ async stop(opts) {
490
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
491
+ const start = Date.now();
492
+ this._stopped = true;
493
+ this._running = false;
494
+ try {
495
+ await Promise.race([
496
+ this.loop,
497
+ new Promise((r) => setTimeout(r, timeoutMs)),
498
+ ]);
499
+ }
500
+ catch {
501
+ /* swallow */
502
+ }
503
+ while (this._inFlight > 0 && Date.now() - start < timeoutMs) {
504
+ await new Promise((r) => setTimeout(r, 10));
505
+ }
506
+ const drainedCleanly = this._inFlight === 0;
507
+ await this.lease.release();
508
+ const durationMs = Date.now() - start;
509
+ this.hooks.recordDrainDuration(durationMs);
510
+ return { drainedCleanly, durationMs };
511
+ }
512
+ }
513
+ //# sourceMappingURL=replication-worker.js.map