@ultimat3/realtime 30.8.0 → 31.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 +72 -82
- package/README.md +36 -40
- package/package.json +8 -6
- package/src/adapter.ts +14 -0
- package/src/apply-patches.ts +8 -5
- package/src/boot.ts +15 -2
- package/src/changefeed.ts +11 -4
- package/src/channel-decl.ts +29 -1
- package/src/channel-publish.ts +17 -19
- package/src/channel-ref.ts +22 -2
- package/src/client-channels.ts +11 -5
- package/src/client-contract.ts +2 -2
- package/src/cursor.ts +5 -5
- package/src/device-agent.ts +2 -2
- package/src/device-errors.ts +1 -1
- package/src/device-socket.ts +1 -1
- package/src/error-titles-server.ts +40 -0
- package/src/error-titles.ts +12 -21
- package/src/errors.ts +43 -10
- package/src/idb-types.ts +6 -0
- package/src/index.ts +5 -27
- package/src/json.ts +7 -3
- package/src/live-contract.ts +4 -4
- package/src/live-definition.ts +9 -6
- package/src/live-deliver.ts +40 -2
- package/src/live-entries.ts +73 -0
- package/src/live-fanout.ts +25 -9
- package/src/live-patch-encode.ts +30 -0
- package/src/live-query-options.ts +11 -0
- package/src/live-query.ts +26 -11
- package/src/live-replicator.ts +24 -21
- package/src/local-store-idb.ts +66 -6
- package/src/matcher-bridge.ts +15 -15
- package/src/nats-transport.ts +5 -5
- package/src/offline-queue-store.ts +69 -0
- package/src/offline-queue.ts +91 -91
- package/src/page-errors.ts +15 -0
- package/src/page-outbox.ts +18 -3
- package/src/pg-connection.ts +9 -1
- package/src/pg-preflight.ts +21 -5
- package/src/pg-replication-dial.ts +95 -0
- package/src/pg-replication-liveness.ts +84 -0
- package/src/pg-replication-position.ts +42 -0
- package/src/pg-replication-row.ts +6 -6
- package/src/pg-replication-stats.ts +28 -0
- package/src/pg-replication.ts +106 -125
- package/src/pg-slot-release.ts +40 -0
- package/src/policy-gate.ts +2 -2
- package/src/query-window.ts +4 -4
- package/src/record-publisher.ts +2 -2
- package/src/record-store.ts +7 -5
- package/src/replication-errors.ts +40 -2
- package/src/replication-resync.ts +38 -0
- package/src/replicator-deadline.ts +33 -0
- package/src/replicator-reconnect.ts +85 -0
- package/src/replicator-resync.ts +30 -0
- package/src/replicator-row.ts +5 -5
- package/src/replicator-subject.ts +28 -0
- package/src/replicator.ts +79 -69
- package/src/server.ts +8 -6
- package/src/socket-engine.ts +2 -2
- package/src/subscriber-gate.ts +14 -10
- package/src/sync-bus-handlers.ts +20 -4
- package/src/sync-protocol.ts +3 -3
- package/src/thundering-herd.ts +17 -24
- package/src/transport-env.ts +20 -48
- package/src/window-lock.ts +15 -3
package/src/record-publisher.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { type RecordProjection, recordProjection } from '@ultimat3/entity/record
|
|
|
15
15
|
import { type ChangeEvent, formatLsn } from './changefeed';
|
|
16
16
|
import type { ChannelEntity } from './channel-decl';
|
|
17
17
|
import type { Transport } from './fanout';
|
|
18
|
-
import type {
|
|
18
|
+
import type { LiveRow } from './json';
|
|
19
19
|
import { changeSubject } from './replicator';
|
|
20
20
|
import { encodeEnvelope } from './replicator-envelope';
|
|
21
21
|
|
|
@@ -109,7 +109,7 @@ export function recordPublisher(options: RecordPublisherOptions): RecordPublishe
|
|
|
109
109
|
const columns = projection.schema.properties ?? {};
|
|
110
110
|
const image = Object.fromEntries(
|
|
111
111
|
Object.entries(row).filter(([property]) => Object.hasOwn(columns, property)),
|
|
112
|
-
) as
|
|
112
|
+
) as LiveRow;
|
|
113
113
|
// Refused here, before any row of the call is sequenced: a keyless row matches no record.
|
|
114
114
|
projection.key(image);
|
|
115
115
|
const orgId = image[tenant];
|
package/src/record-store.ts
CHANGED
|
@@ -258,19 +258,21 @@ export class RecordStore implements RecordSink {
|
|
|
258
258
|
if (carried === undefined || carried.has(rk)) this.#resolve(entry, rk);
|
|
259
259
|
}
|
|
260
260
|
const heard = this.#wait.takeHeard(key);
|
|
261
|
-
|
|
261
|
+
// A Set: the split below asks it once per touched row, and an array made that O(t²).
|
|
262
|
+
const missing = new Set(
|
|
262
263
|
carried === undefined
|
|
263
264
|
? []
|
|
264
|
-
: [...touched].filter((rk) => !carried.has(rk) && heard?.has(rk) !== true)
|
|
265
|
-
|
|
265
|
+
: [...touched].filter((rk) => !carried.has(rk) && heard?.has(rk) !== true),
|
|
266
|
+
);
|
|
267
|
+
if (missing.size > 0) {
|
|
266
268
|
// What the server did answer holds this write already: the twin stays on the rest only.
|
|
267
|
-
const answered = [...touched].filter((rk) => !missing.
|
|
269
|
+
const answered = [...touched].filter((rk) => !missing.has(rk));
|
|
268
270
|
if (answered.length > 0) {
|
|
269
271
|
const confirmed = new Set([...(entry.confirmed ?? []), ...answered]);
|
|
270
272
|
this.#overlays.set(key, { ...entry, confirmed });
|
|
271
273
|
this.#replay();
|
|
272
274
|
}
|
|
273
|
-
this.#wait.start(key,
|
|
275
|
+
this.#wait.start(key, missing, () => this.drop(key));
|
|
274
276
|
return;
|
|
275
277
|
}
|
|
276
278
|
this.#forget(key);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// slot, and the replica identity it warns about.
|
|
1
|
+
// The refusals the Postgres replication half raises: the wire, the connection, its TLS, the lock,
|
|
2
|
+
// the slot (invalidated, or recreated under a resume), and the replica identity it warns about.
|
|
3
3
|
//
|
|
4
4
|
// Split out of `errors.ts` on the one seam this package already draws — these are the only codes
|
|
5
5
|
// no browser can reach, thrown by `pg-*.ts` and the replicator and by nothing on the client half.
|
|
@@ -102,3 +102,41 @@ export class ReplicaIdentityError extends RealtimeError {
|
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Postgres INVALIDATED the slot (`wal_status = 'lost'`): it fell further behind than
|
|
108
|
+
* `max_slot_wal_keep_size` allows and the WAL it needed was removed. It can never stream again,
|
|
109
|
+
* and every restart failed on a generic `X_REPLICATION_FAILED`. The slot name has passed
|
|
110
|
+
* `assertIdentifier`, so the `fix:` is SQL that can be pasted.
|
|
111
|
+
*/
|
|
112
|
+
export class ReplicationSlotInvalidatedError extends RealtimeError {
|
|
113
|
+
constructor(args: { slot: string }) {
|
|
114
|
+
super({
|
|
115
|
+
code: 'X_REPLICATION_SLOT_INVALIDATED',
|
|
116
|
+
cause:
|
|
117
|
+
`postgres invalidated replication slot "${args.slot}" (wal_status = lost): it fell ` +
|
|
118
|
+
'further behind than max_slot_wal_keep_size allows, the WAL it needed is gone, and it ' +
|
|
119
|
+
'can never stream again',
|
|
120
|
+
fix: `SELECT pg_drop_replication_slot('${args.slot}'); # then restart the replicator: it recreates the slot and every live window resyncs`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The replicator resumed from a position, and its slot was not there — dropped by hand, or a
|
|
127
|
+
* failover to a server that never had it. A new slot starts at the server's current WAL, so the
|
|
128
|
+
* changes committed in between were never streamed. **LOGGED, never thrown**: the stream goes on
|
|
129
|
+
* from the new slot, and every live window and channel is resynced from the database at once.
|
|
130
|
+
*/
|
|
131
|
+
export class ReplicationSlotRecreatedError extends RealtimeError {
|
|
132
|
+
constructor(args: { slot: string; from: string }) {
|
|
133
|
+
super({
|
|
134
|
+
code: 'X_REPLICATION_SLOT_RECREATED',
|
|
135
|
+
cause:
|
|
136
|
+
`replication slot "${args.slot}" was gone when the replicator resumed from ${args.from}, ` +
|
|
137
|
+
'so a new one was created and the changes committed in between were never streamed — ' +
|
|
138
|
+
'every live window and channel is resynced from the database',
|
|
139
|
+
fix: 'x doctor --json # find what drops the slot: a failover without slot sync (postgres 17 sync_replication_slots), or a manual pg_drop_replication_slot',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Single responsibility: the bus's one control message — "the change stream has a hole no sequence
|
|
2
|
+
// can show; every reader re-read now" — its subject, its bytes and its decode. The replicator says
|
|
3
|
+
// it when its slot was recreated under a resume position; a sync node answers it as a reconnect.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Under `x.change.>`, so every node already subscribed hears it — and on a token no table can
|
|
7
|
+
* spell (`[a-z_][a-z0-9_]*` refuses `-`), so it is never a table's subject. A node that predates it
|
|
8
|
+
* reads no `table` and no `lsn` and drops it, as it drops any payload it cannot parse.
|
|
9
|
+
*/
|
|
10
|
+
export const RESYNC_SUBJECT = 'x.change.-resync';
|
|
11
|
+
|
|
12
|
+
export interface ResyncSignal {
|
|
13
|
+
/** The replicator run that said it. */
|
|
14
|
+
readonly producer: string;
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function encodeResync(signal: ResyncSignal): string {
|
|
19
|
+
return JSON.stringify({ resync: true, producer: signal.producer, reason: signal.reason });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The signal, or `null` for every other payload — a change envelope included. */
|
|
23
|
+
export function parseResync(payload: string): ResyncSignal | null {
|
|
24
|
+
// A change envelope never opens with this key: cheap to refuse before parsing every change twice.
|
|
25
|
+
if (!payload.startsWith('{"resync":true')) return null;
|
|
26
|
+
try {
|
|
27
|
+
const parsed: unknown = JSON.parse(payload);
|
|
28
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
29
|
+
const shape = parsed as { resync?: unknown; producer?: unknown; reason?: unknown };
|
|
30
|
+
if (shape.resync !== true || typeof shape.producer !== 'string') return null;
|
|
31
|
+
return {
|
|
32
|
+
producer: shape.producer,
|
|
33
|
+
reason: typeof shape.reason === 'string' ? shape.reason : 'the change stream has a gap',
|
|
34
|
+
};
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Single responsibility: the one deadline every goodbye of a replicator `stop()` is held to — the
|
|
2
|
+
// feed's, the lock's, and a start still in flight — on the injected `Scheduler`, so a test fires it.
|
|
3
|
+
|
|
4
|
+
import type { Scheduler } from './thundering-herd';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How long each polite goodbye of a `stop()` may take — the feed's, then the lock's — before the
|
|
8
|
+
* thing is abandoned instead. Both are conversations with a socket; a black-holed one never
|
|
9
|
+
* answers, and a SIGTERM that waited on it never finished.
|
|
10
|
+
*/
|
|
11
|
+
export const STOP_DEADLINE_MS = 5_000;
|
|
12
|
+
|
|
13
|
+
export type DeadlineOutcome =
|
|
14
|
+
| { readonly how: 'done' | 'late' }
|
|
15
|
+
| { readonly how: 'failed'; readonly error: unknown };
|
|
16
|
+
|
|
17
|
+
/** `goodbye` raced against `STOP_DEADLINE_MS`. Never rejects; the timer is cancelled either way. */
|
|
18
|
+
export async function withinStopDeadline(
|
|
19
|
+
schedule: Scheduler,
|
|
20
|
+
goodbye: Promise<void>,
|
|
21
|
+
): Promise<DeadlineOutcome> {
|
|
22
|
+
let cancel = (): void => undefined;
|
|
23
|
+
const deadline = new Promise<{ readonly how: 'late' }>((resolve) => {
|
|
24
|
+
cancel = schedule(() => resolve({ how: 'late' }), STOP_DEADLINE_MS);
|
|
25
|
+
});
|
|
26
|
+
const said = goodbye.then(
|
|
27
|
+
() => ({ how: 'done' as const }),
|
|
28
|
+
(error: unknown) => ({ how: 'failed' as const, error }),
|
|
29
|
+
);
|
|
30
|
+
const outcome = await Promise.race([said, deadline]);
|
|
31
|
+
cancel();
|
|
32
|
+
return outcome;
|
|
33
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Single responsibility: which replicator failures are the CONNECTION going away — a server shut
|
|
2
|
+
// down or restarting, a reset socket, a lock session that died — and the short curve they redial
|
|
3
|
+
// on. Measured on the demo: a database restart cost ~73 s of frozen live windows, because every
|
|
4
|
+
// refused redial while the server was down grew the takeover backoff toward its 30 s cap.
|
|
5
|
+
|
|
6
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import { policyDelay, type ReconnectBackoff } from './thundering-herd';
|
|
8
|
+
|
|
9
|
+
/** The longest the replicator waits between two redials while the connection is what failed. */
|
|
10
|
+
export const REPLICATOR_RECONNECT_MAX_MS = 2_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The redial curve after a lost connection: the FIRST redial is immediate (`replicator.ts`), the
|
|
14
|
+
* rest climb from 250 ms to two seconds. `equal` jitter keeps a floor, so a server still starting
|
|
15
|
+
* is not asked again at once; there is one replicator per database, so there is no herd to spread.
|
|
16
|
+
*/
|
|
17
|
+
export const reconnectBackoff: ReconnectBackoff = {
|
|
18
|
+
base: 250,
|
|
19
|
+
max: REPLICATOR_RECONNECT_MAX_MS,
|
|
20
|
+
factor: 2,
|
|
21
|
+
jitter: 'equal',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** What a socket error carries in `code` (Bun's `connect` rejects with `{ code: 'ECONNREFUSED' }`). */
|
|
25
|
+
const SOCKET_CODES: ReadonlySet<string> = new Set(['ECONNRESET', 'ECONNREFUSED', 'EPIPE']);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* In the rendered failure: an admin, crash or startup shutdown (SQLSTATE 57P01/57P02/57P03, as
|
|
29
|
+
* `describeFields` leads with it), a socket code in a message, and the phrases this package's own
|
|
30
|
+
* Postgres client says when the far end went away — the dial found no listener
|
|
31
|
+
* (`pg-socket.ts`'s `connectFailure`), a write the dead socket refused, an idle session closed, and
|
|
32
|
+
* any lost advisory-lock session (it is lost only when its connection ends).
|
|
33
|
+
*/
|
|
34
|
+
const CONNECTION_TEXT =
|
|
35
|
+
/(?<![\w/])(?:57P0[123]|ECONNRESET|ECONNREFUSED|EPIPE)\b|postgres replication (?:connect|write) failed|the server closed the connection|the advisory lock was lost/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `true` when `failure` — a stream's end reason or what a restart threw — is the connection ending
|
|
39
|
+
* underneath the replicator rather than something a redial cannot fix (a refused publish, a
|
|
40
|
+
* privilege, a decode the package does not speak). Those keep the long curve; this one redials.
|
|
41
|
+
*/
|
|
42
|
+
export function connectionLost(failure: unknown): boolean {
|
|
43
|
+
if (typeof failure === 'object' && failure !== null && 'code' in failure) {
|
|
44
|
+
const { code } = failure;
|
|
45
|
+
if (typeof code === 'string' && SOCKET_CODES.has(code)) return true;
|
|
46
|
+
}
|
|
47
|
+
const text = typeof failure === 'string' ? failure : renderThrowable(failure);
|
|
48
|
+
return CONNECTION_TEXT.test(text);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The takeover loop's two curves and their counts. `long` is the replicator's own `retryDelayMs`
|
|
53
|
+
* (a refused change, a standby, a stream that ended politely); a lost connection redials at once,
|
|
54
|
+
* then on `reconnectBackoff`. Both counts reset only on a PUBLISHED change — "a stream came up" is
|
|
55
|
+
* not progress, so a server that kills every new stream cannot hold the loop at zero.
|
|
56
|
+
*/
|
|
57
|
+
export class RedialCurve {
|
|
58
|
+
readonly #long: (attempt: number) => number;
|
|
59
|
+
readonly #rng: () => number;
|
|
60
|
+
#attempt = 0;
|
|
61
|
+
#reconnects = 0;
|
|
62
|
+
|
|
63
|
+
constructor(long: (attempt: number) => number, rng: () => number) {
|
|
64
|
+
this.#long = long;
|
|
65
|
+
this.#rng = rng;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The wait before the next redial, counted. */
|
|
69
|
+
next(lost: boolean): number {
|
|
70
|
+
if (!lost) {
|
|
71
|
+
const wait = this.#long(this.#attempt);
|
|
72
|
+
this.#attempt += 1;
|
|
73
|
+
return wait;
|
|
74
|
+
}
|
|
75
|
+
const count = this.#reconnects;
|
|
76
|
+
this.#reconnects += 1;
|
|
77
|
+
return count === 0 ? 0 : policyDelay(reconnectBackoff, count, this.#rng);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A change went out: the next death is a first one again, on either curve. */
|
|
81
|
+
progress(): void {
|
|
82
|
+
this.#attempt = 0;
|
|
83
|
+
this.#reconnects = 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Single responsibility: the resync the replicator OWES the bus — set when its feed's slot was
|
|
2
|
+
// recreated under a resume position, paid by one publish, kept owed across runs until one lands.
|
|
3
|
+
// What the message is lives in `replication-resync.ts`; when it is said, in `replicator.ts`.
|
|
4
|
+
|
|
5
|
+
export class ResyncDebt<Run> {
|
|
6
|
+
#owed: string | null = null;
|
|
7
|
+
/** The publish in flight, per run: the boot and a change asking at once publish ONE resync. */
|
|
8
|
+
#paying: { readonly run: Run; readonly paid: Promise<void> } | undefined;
|
|
9
|
+
|
|
10
|
+
/** Owed until a publish lands — across runs, since the next run finds the slot there. */
|
|
11
|
+
owe(reason: string): void {
|
|
12
|
+
this.#owed = reason;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Publishes what is owed, if anything. A refusal rejects and leaves it owed. */
|
|
16
|
+
pay(run: Run, publish: (reason: string) => Promise<void>): Promise<void> {
|
|
17
|
+
const reason = this.#owed;
|
|
18
|
+
if (reason === null) return Promise.resolve();
|
|
19
|
+
if (this.#paying?.run === run) return this.#paying.paid;
|
|
20
|
+
const paid = publish(reason)
|
|
21
|
+
.then(() => {
|
|
22
|
+
if (this.#owed === reason) this.#owed = null;
|
|
23
|
+
})
|
|
24
|
+
.finally(() => {
|
|
25
|
+
if (this.#paying?.paid === paid) this.#paying = undefined;
|
|
26
|
+
});
|
|
27
|
+
this.#paying = { run, paid };
|
|
28
|
+
return paid;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/replicator-row.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// form JSON can carry and the sync node reads it back through the ENTITY's own columns.
|
|
4
4
|
|
|
5
5
|
import { entityForTable } from '@ultimat3/entity';
|
|
6
|
-
import type {
|
|
6
|
+
import type { LiveRow } from './json';
|
|
7
7
|
|
|
8
8
|
const CHUNK = 0x8000;
|
|
9
9
|
|
|
@@ -28,7 +28,7 @@ function fromBase64(text: string): Uint8Array {
|
|
|
28
28
|
* serialises to `{"0":1,"1":2}`, which nothing reads back, so it crosses as base64. Only a
|
|
29
29
|
* top-level value can be one: `arrayOf()` refuses `bytes()` at declaration.
|
|
30
30
|
*/
|
|
31
|
-
export function busRow(row:
|
|
31
|
+
export function busRow(row: LiveRow | null): LiveRow | null {
|
|
32
32
|
if (row === null) return null;
|
|
33
33
|
let out: Record<string, unknown> | undefined;
|
|
34
34
|
for (const [property, value] of Object.entries(row as Record<string, unknown>)) {
|
|
@@ -36,7 +36,7 @@ export function busRow(row: Row | null): Row | null {
|
|
|
36
36
|
out ??= { ...row };
|
|
37
37
|
out[property] = toBase64(value);
|
|
38
38
|
}
|
|
39
|
-
return out === undefined ? row : (out as unknown as
|
|
39
|
+
return out === undefined ? row : (out as unknown as LiveRow);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
@@ -52,7 +52,7 @@ export function busRow(row: Row | null): Row | null {
|
|
|
52
52
|
* its column refuses are all handed back as they arrived — a change that crosses unrevived is the
|
|
53
53
|
* old behaviour; a change dropped is a window that silently diverges.
|
|
54
54
|
*/
|
|
55
|
-
export function reviveBusRow(table: string, row:
|
|
55
|
+
export function reviveBusRow(table: string, row: LiveRow | null): LiveRow | null {
|
|
56
56
|
if (row === null) return null;
|
|
57
57
|
const entity = entityForTable(table);
|
|
58
58
|
if (entity === undefined) return row;
|
|
@@ -71,5 +71,5 @@ export function reviveBusRow(table: string, row: Row | null): Row | null {
|
|
|
71
71
|
// Kept as it arrived — see the function comment.
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
-
return out as unknown as
|
|
74
|
+
return out as unknown as LiveRow;
|
|
75
75
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Single responsibility: where a replicated change goes on the bus and what makes it publishable —
|
|
2
|
+
// the subject a change is published under, and the normalize step that drops the unusable and
|
|
3
|
+
// hoists the tenant out of the row. Split from `replicator.ts`, which re-exports both.
|
|
4
|
+
|
|
5
|
+
import type { ChangeEvent } from './changefeed';
|
|
6
|
+
|
|
7
|
+
export const CHANGE_SUBJECT_PREFIX = 'x.change';
|
|
8
|
+
|
|
9
|
+
/** Every change subject: what a `sync` node subscribes to. A changefeed subject, never a channel. */
|
|
10
|
+
export const CHANGE_SUBJECT_ALL = `${CHANGE_SUBJECT_PREFIX}.>`;
|
|
11
|
+
|
|
12
|
+
/** `x.change.<table>.<orgId>` — tenant in the subject, so fanout filters without parsing a row. */
|
|
13
|
+
export function changeSubject(change: ChangeEvent): string {
|
|
14
|
+
return `${CHANGE_SUBJECT_PREFIX}.${change.table}.${change.orgId ?? '_'}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Drops events the pipeline cannot use and hoists the tenant id out of the row. */
|
|
18
|
+
export function normalize(change: ChangeEvent): ChangeEvent | null {
|
|
19
|
+
// The one change that carries no row by design; nothing to hoist a tenant out of.
|
|
20
|
+
if (change.op === 'truncate') return change;
|
|
21
|
+
const row = change.after ?? change.before;
|
|
22
|
+
if (!row) return null;
|
|
23
|
+
if (change.op === 'insert' && change.after === null) return null;
|
|
24
|
+
if (change.op === 'delete' && change.before === null) return null;
|
|
25
|
+
if (change.orgId !== null) return change;
|
|
26
|
+
const orgId = row['orgId'];
|
|
27
|
+
return typeof orgId === 'string' ? { ...change, orgId } : change;
|
|
28
|
+
}
|
package/src/replicator.ts
CHANGED
|
@@ -16,24 +16,27 @@ import type { AdvisoryLock } from './advisory-lock';
|
|
|
16
16
|
import type { ChangeEvent, ChangeFeed } from './changefeed';
|
|
17
17
|
import { ReplicationFailedError } from './errors';
|
|
18
18
|
import type { Transport } from './fanout';
|
|
19
|
+
import { encodeResync, RESYNC_SUBJECT } from './replication-resync';
|
|
20
|
+
import { withinStopDeadline } from './replicator-deadline';
|
|
19
21
|
import { encodeEnvelope } from './replicator-envelope';
|
|
22
|
+
import { connectionLost, RedialCurve } from './replicator-reconnect';
|
|
23
|
+
import { ResyncDebt } from './replicator-resync';
|
|
24
|
+
import { changeSubject, normalize } from './replicator-subject';
|
|
20
25
|
import {
|
|
21
|
-
type BackoffPolicy,
|
|
22
26
|
defaultBackoff,
|
|
23
27
|
policyDelay,
|
|
28
|
+
type ReconnectBackoff,
|
|
24
29
|
type Rng,
|
|
25
30
|
type Scheduler,
|
|
26
31
|
} from './thundering-herd';
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return `${CHANGE_SUBJECT_PREFIX}.${change.table}.${change.orgId ?? '_'}`;
|
|
36
|
-
}
|
|
33
|
+
// The subjects and the normalize step live next door; their importers keep this path.
|
|
34
|
+
export {
|
|
35
|
+
CHANGE_SUBJECT_ALL,
|
|
36
|
+
CHANGE_SUBJECT_PREFIX,
|
|
37
|
+
changeSubject,
|
|
38
|
+
normalize,
|
|
39
|
+
} from './replicator-subject';
|
|
37
40
|
|
|
38
41
|
export interface ReplicatorOptions {
|
|
39
42
|
readonly feed: ChangeFeed;
|
|
@@ -42,7 +45,7 @@ export interface ReplicatorOptions {
|
|
|
42
45
|
/** Resume position, normally the last lsn this replicator persisted. */
|
|
43
46
|
readonly from?: string;
|
|
44
47
|
readonly subjectOf?: (change: ChangeEvent) => string;
|
|
45
|
-
readonly backoff?:
|
|
48
|
+
readonly backoff?: ReconnectBackoff;
|
|
46
49
|
readonly rng?: Rng;
|
|
47
50
|
/** The timer a restart waits on. Injected so a test proves the takeover loop without sleeping. */
|
|
48
51
|
readonly schedule?: Scheduler;
|
|
@@ -93,12 +96,7 @@ interface Refusal {
|
|
|
93
96
|
times: number;
|
|
94
97
|
}
|
|
95
98
|
|
|
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;
|
|
99
|
+
export { STOP_DEADLINE_MS } from './replicator-deadline';
|
|
102
100
|
|
|
103
101
|
/**
|
|
104
102
|
* How long a publish refused because the bus is AWAY is waited out, in the handler, before the run
|
|
@@ -125,6 +123,8 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
125
123
|
/** A stream is pumping under a lock this process holds. Internal: `running` is stricter. */
|
|
126
124
|
let pumping = false;
|
|
127
125
|
let lastLsn: string | null = null;
|
|
126
|
+
/** Where a run with no `lastLsn` resumes: `options.from`, until a resync names a new timeline. */
|
|
127
|
+
let resumeFrom = options.from;
|
|
128
128
|
let published = 0;
|
|
129
129
|
let skipped = 0;
|
|
130
130
|
let outOfOrder = 0;
|
|
@@ -152,32 +152,18 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
152
152
|
unwatchLock = undefined;
|
|
153
153
|
};
|
|
154
154
|
/**
|
|
155
|
-
* Deaths since a change was last PUBLISHED. "The stream started" is not progress: a
|
|
156
|
-
* bus refuses every time kills each new stream on its first delivery, and counting
|
|
157
|
-
* start made that a redial
|
|
158
|
-
*
|
|
155
|
+
* Deaths since a change was last PUBLISHED, per curve. "The stream started" is not progress: a
|
|
156
|
+
* change the bus refuses every time kills each new stream on its first delivery, and counting
|
|
157
|
+
* from the start made that a redial at the base delay forever, with the WAL retained behind it.
|
|
158
|
+
* A lost CONNECTION (a database restart) redials at once, then on a seconds cap.
|
|
159
159
|
*/
|
|
160
|
-
|
|
160
|
+
const curve = new RedialCurve((count) => retryDelayMs(count), options.rng ?? Math.random);
|
|
161
|
+
/** Counts the starts a `stop()` stopped waiting for — read by the `begin` it gave up on. */
|
|
162
|
+
let givenUp = 0;
|
|
161
163
|
let refusal: Refusal | null = null;
|
|
164
|
+
const resync = new ResyncDebt<Run>();
|
|
162
165
|
|
|
163
|
-
|
|
164
|
-
const bounded = async (
|
|
165
|
-
goodbye: Promise<void>,
|
|
166
|
-
): Promise<
|
|
167
|
-
{ readonly how: 'done' | 'late' } | { readonly how: 'failed'; readonly error: unknown }
|
|
168
|
-
> => {
|
|
169
|
-
let cancel = (): void => undefined;
|
|
170
|
-
const deadline = new Promise<{ readonly how: 'late' }>((resolve) => {
|
|
171
|
-
cancel = schedule(() => resolve({ how: 'late' }), STOP_DEADLINE_MS);
|
|
172
|
-
});
|
|
173
|
-
const said = goodbye.then(
|
|
174
|
-
() => ({ how: 'done' as const }),
|
|
175
|
-
(error: unknown) => ({ how: 'failed' as const, error }),
|
|
176
|
-
);
|
|
177
|
-
const outcome = await Promise.race([said, deadline]);
|
|
178
|
-
cancel();
|
|
179
|
-
return outcome;
|
|
180
|
-
};
|
|
166
|
+
const bounded = (goodbye: Promise<void>) => withinStopDeadline(schedule, goodbye);
|
|
181
167
|
|
|
182
168
|
/**
|
|
183
169
|
* The orderly unlock, bounded. Past the deadline — or refused — the session is dropped instead:
|
|
@@ -227,6 +213,11 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
227
213
|
}
|
|
228
214
|
};
|
|
229
215
|
|
|
216
|
+
const sayResync = (run: Run): Promise<void> =>
|
|
217
|
+
resync.pay(run, (reason) =>
|
|
218
|
+
publishThroughBlip(run, RESYNC_SUBJECT, encodeResync({ producer, reason })),
|
|
219
|
+
);
|
|
220
|
+
|
|
230
221
|
const changeHandler =
|
|
231
222
|
(run: Run) =>
|
|
232
223
|
async (raw: ChangeEvent): Promise<void> => {
|
|
@@ -234,6 +225,8 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
234
225
|
// changes, and publishing them is one of two replicators speaking. A handler that merely
|
|
235
226
|
// returned would let the stream count the change delivered and confirm it.
|
|
236
227
|
if (current !== run || run.over !== null) throw fencedOut(run.over ?? 'a newer run started');
|
|
228
|
+
// Before any change of a stream with a hole, or the run ends as for a refused change.
|
|
229
|
+
await sayResync(run);
|
|
237
230
|
const change = normalize(raw);
|
|
238
231
|
if (!change) {
|
|
239
232
|
skipped += 1;
|
|
@@ -262,7 +255,7 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
262
255
|
lastLsn = change.lsn;
|
|
263
256
|
published += 1;
|
|
264
257
|
// Progress: the next death waits the base delay again, and nothing is owed any more.
|
|
265
|
-
|
|
258
|
+
curve.progress();
|
|
266
259
|
if (refusal !== null) {
|
|
267
260
|
refusal = null;
|
|
268
261
|
if (pumping) failure = null;
|
|
@@ -278,10 +271,17 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
278
271
|
`(${refusal.times} time(s) in a row): ${refusal.error}`;
|
|
279
272
|
|
|
280
273
|
const begin = async (): Promise<boolean> => {
|
|
274
|
+
const givenUpAtEntry = givenUp;
|
|
281
275
|
if (!(await options.lock.tryAcquire())) {
|
|
282
276
|
logger.warn('replicator standby: advisory lock held elsewhere', { key: options.lock.key });
|
|
283
277
|
return false;
|
|
284
278
|
}
|
|
279
|
+
// A stop that gave up waiting for THIS start (`stop()`'s deadline) owns the replicator now; a
|
|
280
|
+
// stop inside the deadline still waits the start out and stops what it began.
|
|
281
|
+
if (stopped && givenUp !== givenUpAtEntry) {
|
|
282
|
+
await release();
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
285
|
producer = uuidV7();
|
|
286
286
|
seq = 0;
|
|
287
287
|
const run: Run = { over: null };
|
|
@@ -301,13 +301,18 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
301
301
|
unwatchLock = options.lock.onLost((reason) => end(`the advisory lock was lost: ${reason}`));
|
|
302
302
|
// A restart resumes where THIS process got to. `options.from` is where the first run began;
|
|
303
303
|
// handing it to the second would have the feed re-deliver everything published since.
|
|
304
|
-
const from = lastLsn ??
|
|
304
|
+
const from = lastLsn ?? resumeFrom;
|
|
305
305
|
const onChange = changeHandler(run);
|
|
306
|
+
// A recreated slot is a NEW timeline: no old position may drop its changes or resume it.
|
|
307
|
+
const onResync = (reason: string): void => {
|
|
308
|
+
resync.owe(reason);
|
|
309
|
+
lastLsn = null;
|
|
310
|
+
resumeFrom = undefined;
|
|
311
|
+
};
|
|
306
312
|
let fed = false;
|
|
307
313
|
try {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
);
|
|
314
|
+
const handlers = { onChange, onEnd: end, onResync };
|
|
315
|
+
await options.feed.start(from === undefined ? handlers : { from, ...handlers });
|
|
311
316
|
fed = true;
|
|
312
317
|
if (run.over !== null) {
|
|
313
318
|
throw new ReplicationFailedError({
|
|
@@ -332,6 +337,13 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
332
337
|
// what `stop()` reads to decide there is anything to tear down.
|
|
333
338
|
pumping = true;
|
|
334
339
|
if (refusal === null) failure = null;
|
|
340
|
+
// Now, not on the next change: a quiet table would stay stale until its next write.
|
|
341
|
+
void sayResync(run).catch((thrown: unknown) => {
|
|
342
|
+
logger.warn('replicator.resync_unpublished', {
|
|
343
|
+
key: options.lock.key,
|
|
344
|
+
error: renderThrowable(thrown),
|
|
345
|
+
});
|
|
346
|
+
});
|
|
335
347
|
logger.info('replicator started', { source: options.feed.source, key: options.lock.key });
|
|
336
348
|
return true;
|
|
337
349
|
};
|
|
@@ -356,15 +368,14 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
356
368
|
policyDelay(backoff, count + 1, options.rng ?? Math.random);
|
|
357
369
|
|
|
358
370
|
/**
|
|
359
|
-
* The takeover loop. One timer at a time, each
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* process replicates, and this one keeps asking in case it dies too.
|
|
371
|
+
* The takeover loop. One timer at a time, each longer than the last until a change is PUBLISHED
|
|
372
|
+
* (`RedialCurve`). `lost` — the connection went (`connectionLost`) — redials at once, then within
|
|
373
|
+
* seconds, unless a change is owed: that death is the bus's. `false` from `launch` is another
|
|
374
|
+
* process holding the lock: it replicates, and this one keeps asking in case it dies too.
|
|
363
375
|
*/
|
|
364
|
-
const retry = (): void => {
|
|
376
|
+
const retry = (lost: boolean): void => {
|
|
365
377
|
if (stopped) return;
|
|
366
|
-
const wait =
|
|
367
|
-
attempt += 1;
|
|
378
|
+
const wait = curve.next(lost && refusal === null);
|
|
368
379
|
cancelRetry = schedule(() => {
|
|
369
380
|
cancelRetry = undefined;
|
|
370
381
|
// `pumping` already: the owner called `start()` itself while this timer was armed.
|
|
@@ -373,12 +384,12 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
373
384
|
void launch().then(
|
|
374
385
|
(started) => {
|
|
375
386
|
if (started) restarts += 1;
|
|
376
|
-
else retry();
|
|
387
|
+
else retry(false);
|
|
377
388
|
},
|
|
378
389
|
(thrown: unknown) => {
|
|
379
390
|
failure = describeFailure(renderThrowable(thrown));
|
|
380
391
|
logger.warn('replicator.restart_failed', { key: options.lock.key, error: failure });
|
|
381
|
-
retry();
|
|
392
|
+
retry(connectionLost(thrown));
|
|
382
393
|
},
|
|
383
394
|
);
|
|
384
395
|
}, wait);
|
|
@@ -404,7 +415,7 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
404
415
|
// socket still held is refused by the run's own fence.
|
|
405
416
|
options.feed.abandon();
|
|
406
417
|
options.lock.abandon();
|
|
407
|
-
retry();
|
|
418
|
+
retry(connectionLost(reason));
|
|
408
419
|
};
|
|
409
420
|
|
|
410
421
|
return {
|
|
@@ -429,8 +440,20 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
|
|
|
429
440
|
stopped = true;
|
|
430
441
|
cancelRetry?.();
|
|
431
442
|
cancelRetry = undefined;
|
|
443
|
+
// Bounded like every other goodbye: a dial can sit in preflight (a slot its last walsender
|
|
444
|
+
// has not let go, a silent server) and a SIGTERM must not wait on it. Past the deadline its
|
|
445
|
+
// run is marked over — so the dial, landing later, stops what it started — and both
|
|
446
|
+
// sessions are dropped now.
|
|
432
447
|
const inFlight = starting;
|
|
433
|
-
if (
|
|
448
|
+
if (
|
|
449
|
+
inFlight !== undefined &&
|
|
450
|
+
(await bounded(inFlight.then(() => undefined))).how === 'late'
|
|
451
|
+
) {
|
|
452
|
+
givenUp += 1;
|
|
453
|
+
if (current !== undefined) current.over ??= 'the replicator was stopped';
|
|
454
|
+
options.feed.abandon();
|
|
455
|
+
options.lock.abandon();
|
|
456
|
+
}
|
|
434
457
|
// An orderly stop is not a failure, whatever the last stream died of.
|
|
435
458
|
failure = null;
|
|
436
459
|
refusal = null;
|
|
@@ -474,16 +497,3 @@ const unrefScheduler: Scheduler = (fn, ms) => {
|
|
|
474
497
|
clearTimeout(handle);
|
|
475
498
|
};
|
|
476
499
|
};
|
|
477
|
-
|
|
478
|
-
/** Drops events the pipeline cannot use and hoists the tenant id out of the row. */
|
|
479
|
-
export function normalize(change: ChangeEvent): ChangeEvent | null {
|
|
480
|
-
// The one change that carries no row by design; nothing to hoist a tenant out of.
|
|
481
|
-
if (change.op === 'truncate') return change;
|
|
482
|
-
const row = change.after ?? change.before;
|
|
483
|
-
if (!row) return null;
|
|
484
|
-
if (change.op === 'insert' && change.after === null) return null;
|
|
485
|
-
if (change.op === 'delete' && change.before === null) return null;
|
|
486
|
-
if (change.orgId !== null) return change;
|
|
487
|
-
const orgId = row['orgId'];
|
|
488
|
-
return typeof orgId === 'string' ? { ...change, orgId } : change;
|
|
489
|
-
}
|