@ultimat3/realtime 23.0.0 → 25.0.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/CLAUDE.md +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/replicator.ts
CHANGED
|
@@ -8,54 +8,31 @@
|
|
|
8
8
|
//
|
|
9
9
|
// The lock is tied to the *session*, so a crashed replicator releases it automatically — no lease
|
|
10
10
|
// renewal, no fencing token, no split brain. A process that fails to take the lock does not start
|
|
11
|
-
// its feed and reports `/readyz` false
|
|
12
|
-
//
|
|
11
|
+
// its feed and reports `/readyz` false. A stream that ENDS — or a lock that is LOST — is this file's
|
|
12
|
+
// to answer: `running` goes false, everything is let go, and the process asks again on a backoff.
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { logger, renderThrowable, uuidV7, withSpan } from '@ultimat3/core';
|
|
15
|
+
import type { AdvisoryLock } from './advisory-lock';
|
|
15
16
|
import type { ChangeEvent, ChangeFeed } from './changefeed';
|
|
17
|
+
import { ReplicationFailedError } from './errors';
|
|
16
18
|
import type { Transport } from './fanout';
|
|
17
|
-
import {
|
|
19
|
+
import { encodeEnvelope } from './replicator-envelope';
|
|
20
|
+
import {
|
|
21
|
+
type BackoffPolicy,
|
|
22
|
+
defaultBackoff,
|
|
23
|
+
policyDelay,
|
|
24
|
+
type Rng,
|
|
25
|
+
type Scheduler,
|
|
26
|
+
} from './thundering-herd';
|
|
18
27
|
|
|
19
28
|
export const CHANGE_SUBJECT_PREFIX = 'x.change';
|
|
20
29
|
|
|
21
30
|
/** Every change subject: what a `sync` node subscribes to. A changefeed subject, never a channel. */
|
|
22
31
|
export const CHANGE_SUBJECT_ALL = `${CHANGE_SUBJECT_PREFIX}.>`;
|
|
23
32
|
|
|
24
|
-
/**
|
|
25
|
-
export interface AdvisoryLock {
|
|
26
|
-
readonly key: string;
|
|
27
|
-
/** `false` means another process holds it — never block, never steal. */
|
|
28
|
-
tryAcquire(): Promise<boolean>;
|
|
29
|
-
release(): Promise<void>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Single-process default: correct for `x dev` and tests, useless across containers (by design). */
|
|
33
|
-
export class InMemoryAdvisoryLock implements AdvisoryLock {
|
|
34
|
-
static readonly #held = new Set<string>();
|
|
35
|
-
readonly key: string;
|
|
36
|
-
#mine = false;
|
|
37
|
-
|
|
38
|
-
constructor(key: string) {
|
|
39
|
-
this.key = key;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async tryAcquire(): Promise<boolean> {
|
|
43
|
-
if (InMemoryAdvisoryLock.#held.has(this.key)) return false;
|
|
44
|
-
InMemoryAdvisoryLock.#held.add(this.key);
|
|
45
|
-
this.#mine = true;
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async release(): Promise<void> {
|
|
50
|
-
if (!this.#mine) return;
|
|
51
|
-
InMemoryAdvisoryLock.#held.delete(this.key);
|
|
52
|
-
this.#mine = false;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** `x.change.<entity>.<orgId>` — tenant in the subject, so fanout filters without parsing a row. */
|
|
33
|
+
/** `x.change.<table>.<orgId>` — tenant in the subject, so fanout filters without parsing a row. */
|
|
57
34
|
export function changeSubject(change: ChangeEvent): string {
|
|
58
|
-
return `${CHANGE_SUBJECT_PREFIX}.${change.
|
|
35
|
+
return `${CHANGE_SUBJECT_PREFIX}.${change.table}.${change.orgId ?? '_'}`;
|
|
59
36
|
}
|
|
60
37
|
|
|
61
38
|
export interface ReplicatorOptions {
|
|
@@ -67,38 +44,34 @@ export interface ReplicatorOptions {
|
|
|
67
44
|
readonly subjectOf?: (change: ChangeEvent) => string;
|
|
68
45
|
readonly backoff?: BackoffPolicy;
|
|
69
46
|
readonly rng?: Rng;
|
|
47
|
+
/** The timer a restart waits on. Injected so a test proves the takeover loop without sleeping. */
|
|
48
|
+
readonly schedule?: Scheduler;
|
|
70
49
|
}
|
|
71
50
|
|
|
72
51
|
export interface ReplicatorStats {
|
|
73
52
|
readonly published: number;
|
|
74
53
|
readonly skipped: number;
|
|
75
54
|
readonly outOfOrder: number;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
* cannot answer it either: a WAL position is a byte offset, so a legitimate next change is an
|
|
85
|
-
* arbitrary jump forwards. A per-publisher counter is the one number a gap is visible in.
|
|
86
|
-
*
|
|
87
|
-
* Both fields are optional on the wire, so a node reading a publisher that predates them simply
|
|
88
|
-
* detects nothing — the same rule the `snapshot.entity` field follows, on a subject no client sees.
|
|
89
|
-
*/
|
|
90
|
-
export interface ChangeEnvelope {
|
|
91
|
-
readonly change: ChangeEvent;
|
|
92
|
-
/** Monotonic within `producer`, from 1. `null` from a publisher that does not sequence. */
|
|
93
|
-
readonly seq: number | null;
|
|
94
|
-
/** Identifies one replicator *run*. A new one restarts `seq`, and that is not a gap. */
|
|
95
|
-
readonly producer: string | null;
|
|
55
|
+
/** Streams this replicator brought back after one ended on its own. */
|
|
56
|
+
readonly restarts: number;
|
|
57
|
+
/**
|
|
58
|
+
* Why this replicator is not running although nothing stopped it — the stream's own reason, the
|
|
59
|
+
* last restart's, or the change (table, lsn, how many times) the bus keeps refusing. `null`
|
|
60
|
+
* while it runs, and after a `stop()`.
|
|
61
|
+
*/
|
|
62
|
+
readonly failure: string | null;
|
|
96
63
|
}
|
|
97
64
|
|
|
98
65
|
export interface Replicator {
|
|
99
66
|
/** `false` = another replicator holds the lock; this process must stay `/readyz` false. */
|
|
100
67
|
start(): Promise<boolean>;
|
|
101
68
|
stop(): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* `true` only while this process is REPLICATING: a stream is pumping under the lock, and no
|
|
71
|
+
* change is waiting on a publish the bus keeps refusing. `false` the moment the stream ends or
|
|
72
|
+
* the lock is lost, and through every restart that has yet to publish the change it owes — the
|
|
73
|
+
* one fact a readiness check has to read.
|
|
74
|
+
*/
|
|
102
75
|
readonly running: boolean;
|
|
103
76
|
lastLsn(): string | null;
|
|
104
77
|
stats(): ReplicatorStats;
|
|
@@ -106,10 +79,39 @@ export interface Replicator {
|
|
|
106
79
|
retryDelayMs(attempt: number): number;
|
|
107
80
|
}
|
|
108
81
|
|
|
109
|
-
|
|
82
|
+
/** One `begin()`: the stream and the lock grant that belong together. */
|
|
83
|
+
interface Run {
|
|
84
|
+
/** Why this run is over — the stream ended, the lock was lost, or it was stopped. */
|
|
85
|
+
over: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The change a run could not publish, kept until one goes out. */
|
|
89
|
+
interface Refusal {
|
|
90
|
+
readonly table: string;
|
|
91
|
+
readonly lsn: string;
|
|
92
|
+
readonly error: string;
|
|
93
|
+
times: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How long each polite goodbye of a `stop()` may take — the feed's, then the lock's — before the
|
|
98
|
+
* thing is abandoned instead. Both are conversations with a socket; a black-holed one never
|
|
99
|
+
* answers, and a SIGTERM that waited on it never finished.
|
|
100
|
+
*/
|
|
101
|
+
export const STOP_DEADLINE_MS = 5_000;
|
|
102
|
+
|
|
103
|
+
const fencedOut = (reason: string): ReplicationFailedError =>
|
|
104
|
+
new ReplicationFailedError({
|
|
105
|
+
stage: 'stream',
|
|
106
|
+
detail: `a change arrived for a run that is over (${reason}), and was not published`,
|
|
107
|
+
fix: 'x doctor --json',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
110
111
|
const subjectOf = options.subjectOf ?? changeSubject;
|
|
111
112
|
const backoff = options.backoff ?? defaultBackoff;
|
|
112
|
-
|
|
113
|
+
/** A stream is pumping under a lock this process holds. Internal: `running` is stricter. */
|
|
114
|
+
let pumping = false;
|
|
113
115
|
let lastLsn: string | null = null;
|
|
114
116
|
let published = 0;
|
|
115
117
|
let skipped = 0;
|
|
@@ -117,103 +119,290 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
|
|
|
117
119
|
// One id per run, not per process: a replicator that took the lock back after a crash publishes
|
|
118
120
|
// from its persisted lsn, and a consumer must read that as a new stream rather than as a gap in
|
|
119
121
|
// the old one.
|
|
120
|
-
let producer =
|
|
122
|
+
let producer = uuidV7();
|
|
121
123
|
let seq = 0;
|
|
122
|
-
/** The start in flight, if any — see `start()` on why `
|
|
124
|
+
/** The start in flight, if any — see `start()` on why `pumping` cannot answer that question. */
|
|
123
125
|
let starting: Promise<boolean> | undefined;
|
|
126
|
+
const schedule = options.schedule ?? unrefScheduler;
|
|
127
|
+
let restarts = 0;
|
|
128
|
+
let failure: string | null = null;
|
|
129
|
+
/** Set by `stop()`: the one thing that ends the takeover loop. */
|
|
130
|
+
let stopped = false;
|
|
131
|
+
/** The run whose stream and lock still speak for this replicator. */
|
|
132
|
+
let current: Run | undefined;
|
|
133
|
+
let cancelRetry: (() => void) | undefined;
|
|
134
|
+
/** Stops listening for the lock being lost. Held only while this replicator holds the lock. */
|
|
135
|
+
let unwatchLock: (() => void) | undefined;
|
|
136
|
+
const unwatch = (): void => {
|
|
137
|
+
unwatchLock?.();
|
|
138
|
+
unwatchLock = undefined;
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Deaths since a change was last PUBLISHED. "The stream started" is not progress: a change the
|
|
142
|
+
* bus refuses every time kills each new stream on its first delivery, and counting from the
|
|
143
|
+
* start made that a redial — two dials, a preflight, a `START_REPLICATION` — at the base delay
|
|
144
|
+
* forever, with the WAL retained behind it.
|
|
145
|
+
*/
|
|
146
|
+
let attempt = 0;
|
|
147
|
+
let refusal: Refusal | null = null;
|
|
124
148
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
await withSpan('realtime.replicate', async () => {
|
|
137
|
-
seq += 1;
|
|
138
|
-
// The envelope is the change plus two fields, flat, so a consumer that only knows about the
|
|
139
|
-
// change reads it unchanged — `parseChange` still answers on the same payload.
|
|
140
|
-
await options.transport.publish(
|
|
141
|
-
subjectOf(change),
|
|
142
|
-
JSON.stringify({ ...change, seq, producer }),
|
|
143
|
-
);
|
|
144
|
-
lastLsn = change.lsn;
|
|
145
|
-
published += 1;
|
|
149
|
+
/** One goodbye against the deadline, on the `schedule` seam. Never rejects. */
|
|
150
|
+
const bounded = async (
|
|
151
|
+
goodbye: Promise<void>,
|
|
152
|
+
): Promise<
|
|
153
|
+
{ readonly how: 'done' | 'late' } | { readonly how: 'failed'; readonly error: unknown }
|
|
154
|
+
> => {
|
|
155
|
+
let cancel = (): void => undefined;
|
|
156
|
+
const deadline = new Promise<{ readonly how: 'late' }>((resolve) => {
|
|
157
|
+
cancel = schedule(() => resolve({ how: 'late' }), STOP_DEADLINE_MS);
|
|
146
158
|
});
|
|
159
|
+
const said = goodbye.then(
|
|
160
|
+
() => ({ how: 'done' as const }),
|
|
161
|
+
(error: unknown) => ({ how: 'failed' as const, error }),
|
|
162
|
+
);
|
|
163
|
+
const outcome = await Promise.race([said, deadline]);
|
|
164
|
+
cancel();
|
|
165
|
+
return outcome;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The orderly unlock, bounded. Past the deadline — or refused — the session is dropped instead:
|
|
170
|
+
* closing it releases the lock just the same.
|
|
171
|
+
*/
|
|
172
|
+
const release = async (): Promise<void> => {
|
|
173
|
+
if ((await bounded(options.lock.release())).how !== 'done') options.lock.abandon();
|
|
147
174
|
};
|
|
148
175
|
|
|
176
|
+
/** The orderly stream stop, bounded the same way. A refusal is handed back for the caller. */
|
|
177
|
+
const quiet = async (): Promise<unknown> => {
|
|
178
|
+
const outcome = await bounded(options.feed.stop());
|
|
179
|
+
if (outcome.how !== 'done') options.feed.abandon();
|
|
180
|
+
return outcome.how === 'failed' ? outcome.error : undefined;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const changeHandler =
|
|
184
|
+
(run: Run) =>
|
|
185
|
+
async (raw: ChangeEvent): Promise<void> => {
|
|
186
|
+
// Fenced by run, and REFUSED rather than dropped: after a lock loss the socket still holds
|
|
187
|
+
// changes, and publishing them is one of two replicators speaking. A handler that merely
|
|
188
|
+
// returned would let the stream count the change delivered and confirm it.
|
|
189
|
+
if (current !== run || run.over !== null) throw fencedOut(run.over ?? 'a newer run started');
|
|
190
|
+
const change = normalize(raw);
|
|
191
|
+
if (!change) {
|
|
192
|
+
skipped += 1;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (lastLsn !== null && change.lsn <= lastLsn) {
|
|
196
|
+
// At-least-once delivery is the feed's contract, so a repeat is expected, not an error.
|
|
197
|
+
outOfOrder += 1;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
await withSpan('realtime.replicate', async () => {
|
|
201
|
+
seq += 1;
|
|
202
|
+
try {
|
|
203
|
+
await options.transport.publish(subjectOf(change), encodeEnvelope(change, seq, producer));
|
|
204
|
+
} catch (thrown) {
|
|
205
|
+
const error = renderThrowable(thrown);
|
|
206
|
+
const same = refusal !== null && refusal.lsn === change.lsn;
|
|
207
|
+
refusal = {
|
|
208
|
+
table: change.table,
|
|
209
|
+
lsn: change.lsn,
|
|
210
|
+
error,
|
|
211
|
+
times: same ? (refusal?.times ?? 0) + 1 : 1,
|
|
212
|
+
};
|
|
213
|
+
throw thrown;
|
|
214
|
+
}
|
|
215
|
+
lastLsn = change.lsn;
|
|
216
|
+
published += 1;
|
|
217
|
+
// Progress: the next death waits the base delay again, and nothing is owed any more.
|
|
218
|
+
attempt = 0;
|
|
219
|
+
if (refusal !== null) {
|
|
220
|
+
refusal = null;
|
|
221
|
+
if (pumping) failure = null;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/** What `stats().failure` says while a change keeps being refused: which one, and how often. */
|
|
227
|
+
const describeFailure = (reason: string): string =>
|
|
228
|
+
refusal === null
|
|
229
|
+
? reason
|
|
230
|
+
: `${refusal.table} at lsn ${refusal.lsn} could not be published ` +
|
|
231
|
+
`(${refusal.times} time(s) in a row): ${refusal.error}`;
|
|
232
|
+
|
|
149
233
|
const begin = async (): Promise<boolean> => {
|
|
150
234
|
if (!(await options.lock.tryAcquire())) {
|
|
151
235
|
logger.warn('replicator standby: advisory lock held elsewhere', { key: options.lock.key });
|
|
152
236
|
return false;
|
|
153
237
|
}
|
|
154
|
-
producer =
|
|
238
|
+
producer = uuidV7();
|
|
155
239
|
seq = 0;
|
|
240
|
+
const run: Run = { over: null };
|
|
241
|
+
current = run;
|
|
242
|
+
const end = (reason: string): void => {
|
|
243
|
+
if (run.over !== null) return;
|
|
244
|
+
run.over = reason;
|
|
245
|
+
// Not yet `pumping`: `begin` is still between the feed's start and its own last line, and
|
|
246
|
+
// reads `run.over` there. Not `current`: a stop or a newer run owns the replicator now.
|
|
247
|
+
if (current === run && pumping) lost(reason);
|
|
248
|
+
};
|
|
249
|
+
// The lock is a session, and a session can die while the stream stays up — or while it is
|
|
250
|
+
// still being dialled. Postgres then gives the lock to whoever asks next, and the loss is said
|
|
251
|
+
// ONCE: recorded on the run, so a start that was mid-dial reads it below instead of going on
|
|
252
|
+
// to stream, ready and unlocked, for the rest of the process's life.
|
|
253
|
+
unwatch();
|
|
254
|
+
unwatchLock = options.lock.onLost((reason) => end(`the advisory lock was lost: ${reason}`));
|
|
255
|
+
// A restart resumes where THIS process got to. `options.from` is where the first run began;
|
|
256
|
+
// handing it to the second would have the feed re-deliver everything published since.
|
|
257
|
+
const from = lastLsn ?? options.from;
|
|
258
|
+
const onChange = changeHandler(run);
|
|
259
|
+
let fed = false;
|
|
156
260
|
try {
|
|
157
261
|
await options.feed.start(
|
|
158
|
-
|
|
262
|
+
from === undefined ? { onChange, onEnd: end } : { from, onChange, onEnd: end },
|
|
159
263
|
);
|
|
264
|
+
fed = true;
|
|
265
|
+
if (run.over !== null) {
|
|
266
|
+
throw new ReplicationFailedError({
|
|
267
|
+
stage: 'stream',
|
|
268
|
+
detail: `the run was over as it started: ${run.over}`,
|
|
269
|
+
fix: 'x doctor --json',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
160
272
|
} catch (thrown) {
|
|
161
|
-
// A start that failed holds NOTHING. `
|
|
273
|
+
// A start that failed holds NOTHING. `pumping` stays false so the takeover loop's next
|
|
162
274
|
// `start()` runs `begin` again instead of being answered `true` by a memo over a feed that
|
|
163
275
|
// never pumped, and the lock goes back so a standby can take the slot rather than waiting on
|
|
164
|
-
// a holder that is not replicating.
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
await
|
|
276
|
+
// a holder that is not replicating. A feed that DID start — the lock went while it dialled —
|
|
277
|
+
// is stopped first: it must not outlive the grant it was started under.
|
|
278
|
+
run.over ??= renderThrowable(thrown);
|
|
279
|
+
unwatch();
|
|
280
|
+
if (fed) await quiet();
|
|
281
|
+
await release();
|
|
170
282
|
throw thrown;
|
|
171
283
|
}
|
|
172
|
-
// AFTER the feed is pumping, never before: `
|
|
284
|
+
// AFTER the feed is pumping, never before: `pumping` is what `start()` answers `true` from and
|
|
173
285
|
// what `stop()` reads to decide there is anything to tear down.
|
|
174
|
-
|
|
286
|
+
pumping = true;
|
|
287
|
+
if (refusal === null) failure = null;
|
|
175
288
|
logger.info('replicator started', { source: options.feed.source, key: options.lock.key });
|
|
176
289
|
return true;
|
|
177
290
|
};
|
|
178
291
|
|
|
292
|
+
/** Memoised — see `start()`. The takeover loop calls this, never `start()`: it must not un-stop. */
|
|
293
|
+
const launch = (): Promise<boolean> => {
|
|
294
|
+
if (pumping) return Promise.resolve(true);
|
|
295
|
+
const inFlight = starting;
|
|
296
|
+
if (inFlight !== undefined) return inFlight;
|
|
297
|
+
// Cleared however it settles: a `false` is "another node holds the lock", and the takeover
|
|
298
|
+
// loop asks again a `retryDelayMs` later. A memo that stuck would make this node a permanent
|
|
299
|
+
// standby of a slot whose holder has already died.
|
|
300
|
+
const begun = begin().finally(() => {
|
|
301
|
+
if (starting === begun) starting = undefined;
|
|
302
|
+
});
|
|
303
|
+
starting = begun;
|
|
304
|
+
return begun;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const retryDelayMs = (count: number): number =>
|
|
308
|
+
// This method's contract is 0-based (`retryDelayMs(0)` is the base); core counts from 1.
|
|
309
|
+
policyDelay(backoff, count + 1, options.rng ?? Math.random);
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The takeover loop. One timer at a time, each on a longer delay than the last until a change
|
|
313
|
+
* is PUBLISHED — which is what resets the count (`changeHandler`), not a stream coming up.
|
|
314
|
+
* `false` from `launch` is another process holding the lock, which is this loop working: that
|
|
315
|
+
* process replicates, and this one keeps asking in case it dies too.
|
|
316
|
+
*/
|
|
317
|
+
const retry = (): void => {
|
|
318
|
+
if (stopped) return;
|
|
319
|
+
const wait = retryDelayMs(attempt);
|
|
320
|
+
attempt += 1;
|
|
321
|
+
cancelRetry = schedule(() => {
|
|
322
|
+
cancelRetry = undefined;
|
|
323
|
+
// `pumping` already: the owner called `start()` itself while this timer was armed.
|
|
324
|
+
if (stopped || pumping) return;
|
|
325
|
+
// Voided: both outcomes are handled right here, and a timer has nobody to hand a promise to.
|
|
326
|
+
void launch().then(
|
|
327
|
+
(started) => {
|
|
328
|
+
if (started) restarts += 1;
|
|
329
|
+
else retry();
|
|
330
|
+
},
|
|
331
|
+
(thrown: unknown) => {
|
|
332
|
+
failure = describeFailure(renderThrowable(thrown));
|
|
333
|
+
logger.warn('replicator.restart_failed', { key: options.lock.key, error: failure });
|
|
334
|
+
retry();
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
}, wait);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A stream that ended on its own, or a lock that was lost. It used to be recorded where nothing
|
|
342
|
+
* read it: `running` stayed `true`, the lock stayed held, and the fleet's live windows froze
|
|
343
|
+
* until the pod was restarted. Everything the run held is let go here and the loop above asks
|
|
344
|
+
* for it back.
|
|
345
|
+
*
|
|
346
|
+
* Both are ABANDONED, never asked, and synchronously: a black-holed socket is what produces a
|
|
347
|
+
* lost stream, and a goodbye to one — the feed's or the lock's — is a recovery that never
|
|
348
|
+
* reaches its retry. Being synchronous is also what leaves no window for a `start()` to be
|
|
349
|
+
* answered over a run still being torn down.
|
|
350
|
+
*/
|
|
351
|
+
const lost = (reason: string): void => {
|
|
352
|
+
pumping = false;
|
|
353
|
+
failure = describeFailure(reason);
|
|
354
|
+
unwatch();
|
|
355
|
+
logger.error('replicator.stream_ended', { key: options.lock.key, error: failure });
|
|
356
|
+
// The feed first: it must be silent before the grant it ran under is given up. Anything its
|
|
357
|
+
// socket still held is refused by the run's own fence.
|
|
358
|
+
options.feed.abandon();
|
|
359
|
+
options.lock.abandon();
|
|
360
|
+
retry();
|
|
361
|
+
};
|
|
362
|
+
|
|
179
363
|
return {
|
|
180
364
|
/**
|
|
181
|
-
* Deliberately NOT `async`: `
|
|
365
|
+
* Deliberately NOT `async`: `pumping` is set after an `await` on the lock, so a guard that
|
|
182
366
|
* awaited anything before registering has the same hole it is meant to close. Two overlapping
|
|
183
|
-
* starts both passed
|
|
367
|
+
* starts both passed the guard, both were told they held the lock — a holder's
|
|
184
368
|
* `tryAcquire()` answers `true` — and both ran `feed.start()`: one replication slot with two
|
|
185
369
|
* pumps, publishing every change twice under two `seq` generations of one producer id, which
|
|
186
370
|
* every sync node's `SeqGapDetector` reads as a gap and repairs by re-snapshotting the fleet.
|
|
187
371
|
* Same shape as `packages/core/src/lifecycle.ts`'s `drain()`.
|
|
188
372
|
*/
|
|
189
373
|
start(): Promise<boolean> {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (inFlight !== undefined) return inFlight;
|
|
193
|
-
// Cleared however it settles: a `false` is "another node holds the lock", and the takeover
|
|
194
|
-
// loop asks again a `retryDelayMs` later. A memo that stuck would make this node a permanent
|
|
195
|
-
// standby of a slot whose holder has already died.
|
|
196
|
-
const attempt = begin().finally(() => {
|
|
197
|
-
if (starting === attempt) starting = undefined;
|
|
198
|
-
});
|
|
199
|
-
starting = attempt;
|
|
200
|
-
return attempt;
|
|
374
|
+
stopped = false;
|
|
375
|
+
return launch();
|
|
201
376
|
},
|
|
202
377
|
|
|
203
378
|
async stop(): Promise<void> {
|
|
204
|
-
// A stop racing a start waits it out: `
|
|
379
|
+
// A stop racing a start waits it out: `pumping` is false for the whole of `begin`, so the
|
|
205
380
|
// early return below would leave the feed it is about to start pumping into a replicator
|
|
206
381
|
// nothing intends to stop, with the advisory lock still held.
|
|
382
|
+
stopped = true;
|
|
383
|
+
cancelRetry?.();
|
|
384
|
+
cancelRetry = undefined;
|
|
207
385
|
const inFlight = starting;
|
|
208
386
|
if (inFlight !== undefined) await inFlight.catch(() => false);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
387
|
+
// An orderly stop is not a failure, whatever the last stream died of.
|
|
388
|
+
failure = null;
|
|
389
|
+
refusal = null;
|
|
390
|
+
if (!pumping) return;
|
|
391
|
+
pumping = false;
|
|
392
|
+
if (current !== undefined) current.over ??= 'the replicator was stopped';
|
|
393
|
+
current = undefined;
|
|
394
|
+
unwatch();
|
|
395
|
+
// Whatever the feed said: a lock kept because the stream would not close politely is a
|
|
396
|
+
// slot no standby can take. Its refusal is still the one the caller is owed.
|
|
397
|
+
const refused = await quiet();
|
|
398
|
+
await release();
|
|
399
|
+
if (refused !== undefined) throw refused;
|
|
213
400
|
},
|
|
214
401
|
|
|
215
402
|
get running(): boolean {
|
|
216
|
-
|
|
403
|
+
// Replicating, not merely connected: a stream that is up while the change it owes is still
|
|
404
|
+
// being refused has published nothing, and must not read as ready between two of its deaths.
|
|
405
|
+
return pumping && refusal === null;
|
|
217
406
|
},
|
|
218
407
|
|
|
219
408
|
lastLsn(): string | null {
|
|
@@ -221,16 +410,22 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
|
|
|
221
410
|
},
|
|
222
411
|
|
|
223
412
|
stats(): ReplicatorStats {
|
|
224
|
-
return { published, skipped, outOfOrder };
|
|
413
|
+
return { published, skipped, outOfOrder, restarts, failure };
|
|
225
414
|
},
|
|
226
415
|
|
|
227
|
-
retryDelayMs
|
|
228
|
-
// This method's contract is 0-based (`retryDelayMs(0)` is the base); core counts from 1.
|
|
229
|
-
return policyDelay(backoff, attempt + 1, options.rng ?? Math.random);
|
|
230
|
-
},
|
|
416
|
+
retryDelayMs,
|
|
231
417
|
};
|
|
232
418
|
}
|
|
233
419
|
|
|
420
|
+
/** The production timer: a pending restart must never be what keeps a draining process alive. */
|
|
421
|
+
const unrefScheduler: Scheduler = (fn, ms) => {
|
|
422
|
+
const handle = setTimeout(fn, ms);
|
|
423
|
+
handle.unref?.();
|
|
424
|
+
return () => {
|
|
425
|
+
clearTimeout(handle);
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
|
|
234
429
|
/** Drops events the pipeline cannot use and hoists the tenant id out of the row. */
|
|
235
430
|
export function normalize(change: ChangeEvent): ChangeEvent | null {
|
|
236
431
|
// The one change that carries no row by design; nothing to hoist a tenant out of.
|
|
@@ -243,76 +438,3 @@ export function normalize(change: ChangeEvent): ChangeEvent | null {
|
|
|
243
438
|
const orgId = row['orgId'];
|
|
244
439
|
return typeof orgId === 'string' ? { ...change, orgId } : change;
|
|
245
440
|
}
|
|
246
|
-
|
|
247
|
-
/** Sync-node side of the bus: decode a published change back into a `ChangeEvent`. */
|
|
248
|
-
export function parseChange(payload: string): ChangeEvent | null {
|
|
249
|
-
return parseEnvelope(payload)?.change ?? null;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** The same decode, keeping the two fields a gap is visible in. `parseChange` is this, narrowed. */
|
|
253
|
-
export function parseEnvelope(payload: string): ChangeEnvelope | null {
|
|
254
|
-
try {
|
|
255
|
-
const parsed: unknown = JSON.parse(payload);
|
|
256
|
-
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
257
|
-
const shape = parsed as Partial<ChangeEvent> & { seq?: unknown; producer?: unknown };
|
|
258
|
-
if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
|
|
259
|
-
if (
|
|
260
|
-
shape.op !== 'insert' &&
|
|
261
|
-
shape.op !== 'update' &&
|
|
262
|
-
shape.op !== 'delete' &&
|
|
263
|
-
shape.op !== 'truncate'
|
|
264
|
-
) {
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
267
|
-
return {
|
|
268
|
-
change: {
|
|
269
|
-
entity: shape.entity,
|
|
270
|
-
op: shape.op,
|
|
271
|
-
before: shape.before ?? null,
|
|
272
|
-
after: shape.after ?? null,
|
|
273
|
-
lsn: shape.lsn,
|
|
274
|
-
txid: typeof shape.txid === 'string' ? shape.txid : '',
|
|
275
|
-
orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
|
|
276
|
-
at: typeof shape.at === 'number' ? shape.at : 0,
|
|
277
|
-
// Only the minted shape crosses: a frame decoder refuses anything else, so a malformed
|
|
278
|
-
// label here would cost every member the whole frame rather than one page its echo.
|
|
279
|
-
...(isWriteDigest(shape.write) ? { write: shape.write } : {}),
|
|
280
|
-
},
|
|
281
|
-
seq: typeof shape.seq === 'number' && Number.isFinite(shape.seq) ? shape.seq : null,
|
|
282
|
-
producer: typeof shape.producer === 'string' ? shape.producer : null,
|
|
283
|
-
};
|
|
284
|
-
} catch {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* The consume-side twin of the publisher's counter, and the only thing on a `sync` node that can
|
|
291
|
-
* say "this node missed changes". Publish-side duplicate and out-of-order guards already existed;
|
|
292
|
-
* there was no equivalent here, so a NATS blip during a rolling restart was eleven changes that
|
|
293
|
-
* simply never happened as far as every subscriber on this node could tell.
|
|
294
|
-
*
|
|
295
|
-
* Per producer, because a replicator restart legitimately rewinds the counter. A repeat or a
|
|
296
|
-
* reordering is *not* reported as a gap — the window's own lsn guard refuses those — and neither is
|
|
297
|
-
* the first message of a stream: a node that joined late has missed everything by definition, and
|
|
298
|
-
* every subscription it holds started after it did.
|
|
299
|
-
*/
|
|
300
|
-
export class SeqGapDetector {
|
|
301
|
-
readonly #next = new Map<string, number>();
|
|
302
|
-
|
|
303
|
-
/** `true` when at least one message between the last one and this one was never delivered. */
|
|
304
|
-
observe(envelope: ChangeEnvelope): boolean {
|
|
305
|
-
const { producer, seq } = envelope;
|
|
306
|
-
if (producer === null || seq === null) return false;
|
|
307
|
-
const expected = this.#next.get(producer);
|
|
308
|
-
// Never backwards: a redelivery must not lower the bar and turn the next legitimate message
|
|
309
|
-
// into a gap of its own.
|
|
310
|
-
this.#next.set(producer, Math.max(expected ?? 0, seq + 1));
|
|
311
|
-
return expected !== undefined && seq > expected;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** Producers this node has read. Bounded by replicator restarts, so it is swept on a drain. */
|
|
315
|
-
forget(): void {
|
|
316
|
-
this.#next.clear();
|
|
317
|
-
}
|
|
318
|
-
}
|