@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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/lambda/index.d.ts +77 -0
- package/dist/lambda/index.d.ts.map +1 -0
- package/dist/lambda/index.js +130 -0
- package/dist/lambda/index.js.map +1 -0
- 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 +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
|