@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.
Files changed (67) hide show
  1. package/CLAUDE.md +72 -82
  2. package/README.md +36 -40
  3. package/package.json +8 -6
  4. package/src/adapter.ts +14 -0
  5. package/src/apply-patches.ts +8 -5
  6. package/src/boot.ts +15 -2
  7. package/src/changefeed.ts +11 -4
  8. package/src/channel-decl.ts +29 -1
  9. package/src/channel-publish.ts +17 -19
  10. package/src/channel-ref.ts +22 -2
  11. package/src/client-channels.ts +11 -5
  12. package/src/client-contract.ts +2 -2
  13. package/src/cursor.ts +5 -5
  14. package/src/device-agent.ts +2 -2
  15. package/src/device-errors.ts +1 -1
  16. package/src/device-socket.ts +1 -1
  17. package/src/error-titles-server.ts +40 -0
  18. package/src/error-titles.ts +12 -21
  19. package/src/errors.ts +43 -10
  20. package/src/idb-types.ts +6 -0
  21. package/src/index.ts +5 -27
  22. package/src/json.ts +7 -3
  23. package/src/live-contract.ts +4 -4
  24. package/src/live-definition.ts +9 -6
  25. package/src/live-deliver.ts +40 -2
  26. package/src/live-entries.ts +73 -0
  27. package/src/live-fanout.ts +25 -9
  28. package/src/live-patch-encode.ts +30 -0
  29. package/src/live-query-options.ts +11 -0
  30. package/src/live-query.ts +26 -11
  31. package/src/live-replicator.ts +24 -21
  32. package/src/local-store-idb.ts +66 -6
  33. package/src/matcher-bridge.ts +15 -15
  34. package/src/nats-transport.ts +5 -5
  35. package/src/offline-queue-store.ts +69 -0
  36. package/src/offline-queue.ts +91 -91
  37. package/src/page-errors.ts +15 -0
  38. package/src/page-outbox.ts +18 -3
  39. package/src/pg-connection.ts +9 -1
  40. package/src/pg-preflight.ts +21 -5
  41. package/src/pg-replication-dial.ts +95 -0
  42. package/src/pg-replication-liveness.ts +84 -0
  43. package/src/pg-replication-position.ts +42 -0
  44. package/src/pg-replication-row.ts +6 -6
  45. package/src/pg-replication-stats.ts +28 -0
  46. package/src/pg-replication.ts +106 -125
  47. package/src/pg-slot-release.ts +40 -0
  48. package/src/policy-gate.ts +2 -2
  49. package/src/query-window.ts +4 -4
  50. package/src/record-publisher.ts +2 -2
  51. package/src/record-store.ts +7 -5
  52. package/src/replication-errors.ts +40 -2
  53. package/src/replication-resync.ts +38 -0
  54. package/src/replicator-deadline.ts +33 -0
  55. package/src/replicator-reconnect.ts +85 -0
  56. package/src/replicator-resync.ts +30 -0
  57. package/src/replicator-row.ts +5 -5
  58. package/src/replicator-subject.ts +28 -0
  59. package/src/replicator.ts +79 -69
  60. package/src/server.ts +8 -6
  61. package/src/socket-engine.ts +2 -2
  62. package/src/subscriber-gate.ts +14 -10
  63. package/src/sync-bus-handlers.ts +20 -4
  64. package/src/sync-protocol.ts +3 -3
  65. package/src/thundering-herd.ts +17 -24
  66. package/src/transport-env.ts +20 -48
  67. package/src/window-lock.ts +15 -3
@@ -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 { Row } from './json';
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 Row;
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];
@@ -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
- const missing =
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
- if (missing.length > 0) {
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.includes(rk));
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, new Set(missing), () => this.drop(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 five refusals the Postgres replication half raises: the wire, the connection, its TLS, 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
+ }
@@ -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 { Row } from './json';
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: Row | null): Row | null {
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 Row);
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: Row | null): Row | null {
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 Row;
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
- export const CHANGE_SUBJECT_PREFIX = 'x.change';
29
-
30
- /** Every change subject: what a `sync` node subscribes to. A changefeed subject, never a channel. */
31
- export const CHANGE_SUBJECT_ALL = `${CHANGE_SUBJECT_PREFIX}.>`;
32
-
33
- /** `x.change.<table>.<orgId>` — tenant in the subject, so fanout filters without parsing a row. */
34
- export function changeSubject(change: ChangeEvent): string {
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?: BackoffPolicy;
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 change the
156
- * bus refuses every time kills each new stream on its first delivery, and counting from the
157
- * start made that a redial — two dials, a preflight, a `START_REPLICATION` — at the base delay
158
- * forever, with the WAL retained behind it.
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
- let attempt = 0;
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
- /** One goodbye against the deadline, on the `schedule` seam. Never rejects. */
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
- attempt = 0;
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 ?? options.from;
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
- await options.feed.start(
309
- from === undefined ? { onChange, onEnd: end } : { from, onChange, onEnd: end },
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 on a longer delay than the last until a change
360
- * is PUBLISHED — which is what resets the count (`changeHandler`), not a stream coming up.
361
- * `false` from `launch` is another process holding the lock, which is this loop working: that
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 = retryDelayMs(attempt);
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 (inFlight !== undefined) await inFlight.catch(() => false);
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
- }