@ultimat3/realtime 30.9.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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Single responsibility: decide, once per status interval, whether a replication stream's far end
|
|
2
|
+
// is still there — the read deadline and the confirm deadline. Counted in INTERVALS rather than on
|
|
3
|
+
// a clock: the feed's clock is injected (and frozen under test), the interval timer is not, and a
|
|
4
|
+
// deadline that is a multiple of the interval cannot disagree with the interval it is built on.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Intervals a stream may go without a byte from the walsender while the pump waits on a read, and
|
|
8
|
+
* intervals one standby status update may stay unsettled, before either is a death.
|
|
9
|
+
*
|
|
10
|
+
* Three, at the default 10 s interval, is 30–40 s — inside postgres' own 60 s `wal_sender_timeout`,
|
|
11
|
+
* the same bound `MAX_CONFIRM_FAILURES` sets for writes that reject. Every timer confirm asks for a
|
|
12
|
+
* reply (`replyRequested = 1`), so a live walsender is never silent for a whole interval.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_SILENT_INTERVALS = 3;
|
|
15
|
+
|
|
16
|
+
/** A confirm on the wire: the identity the interval that sent it holds, and what it settles. */
|
|
17
|
+
export type ConfirmAttempt = symbol;
|
|
18
|
+
|
|
19
|
+
export class StreamLiveness {
|
|
20
|
+
#silent = 0;
|
|
21
|
+
#reading = false;
|
|
22
|
+
#inFlight: ConfirmAttempt | null = null;
|
|
23
|
+
|
|
24
|
+
/** A new run: the last run's silence and its parked confirm are not this one's. */
|
|
25
|
+
reset(): void {
|
|
26
|
+
this.#silent = 0;
|
|
27
|
+
this.#reading = false;
|
|
28
|
+
this.#inFlight = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The pump is parked on the socket. Silence is measured only from here. */
|
|
32
|
+
awaitingRead(): void {
|
|
33
|
+
this.#reading = true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The read returned. A pump busy inside `onChange` is not reading, so a slow consumer is never
|
|
38
|
+
* mistaken for a dead walsender: its bytes are in the socket buffer, not missing.
|
|
39
|
+
*/
|
|
40
|
+
heard(): void {
|
|
41
|
+
this.#reading = false;
|
|
42
|
+
this.#silent = 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One interval elapsed. `true` once `MAX_SILENT_INTERVALS` of them passed with the pump waiting
|
|
47
|
+
* and nothing arriving. Decided BEFORE this interval is counted, so the interval that sends the
|
|
48
|
+
* reply request is given the whole of the next one to be answered.
|
|
49
|
+
*/
|
|
50
|
+
silentTooLong(): boolean {
|
|
51
|
+
if (this.#silent >= MAX_SILENT_INTERVALS) return true;
|
|
52
|
+
if (this.#reading) this.#silent += 1;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* An identity for the confirm this interval sends, or `null` when the last one has not settled. A
|
|
58
|
+
* write parked on a `drain` that never fires is never a rejection, so "still in flight at the
|
|
59
|
+
* next interval" is the only way it is ever seen — and nothing more is queued behind it.
|
|
60
|
+
*/
|
|
61
|
+
begin(): ConfirmAttempt | null {
|
|
62
|
+
if (this.#inFlight !== null) return null;
|
|
63
|
+
this.#inFlight = Symbol('confirm');
|
|
64
|
+
return this.#inFlight;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Settled, either way. One from a run `reset()` has since replaced changes nothing. */
|
|
68
|
+
settle(attempt: ConfirmAttempt): void {
|
|
69
|
+
if (this.#inFlight === attempt) this.#inFlight = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The death of a stream nobody answered: what was measured, and what happens next. */
|
|
74
|
+
export const silenceReason = (intervalMs: number): string =>
|
|
75
|
+
`nothing from the walsender for ${MAX_SILENT_INTERVALS} status intervals ` +
|
|
76
|
+
`(${MAX_SILENT_INTERVALS * intervalMs} ms) though every one asked for a reply — the connection ` +
|
|
77
|
+
'is half-open (a failover that moved the address, a NAT drop, a partition); the stream is ' +
|
|
78
|
+
'dropped without a goodbye so the replicator redials';
|
|
79
|
+
|
|
80
|
+
/** The death of a stream whose standby status update never settled. */
|
|
81
|
+
export const unsettledReason = (intervals: number, intervalMs: number): string =>
|
|
82
|
+
`a standby status update has not settled for ${intervals} status intervals ` +
|
|
83
|
+
`(${intervals * intervalMs} ms) — the socket accepts no writes; the stream is dropped without ` +
|
|
84
|
+
'a goodbye so the replicator redials';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Single responsibility: the positions a replication stream speaks — the change lsn it delivers
|
|
2
|
+
// under, the commit position a resume restarts from, and the standby status update that tells the
|
|
3
|
+
// walsender how far it got. Bytes and spellings only; when to send one is `pg-replication.ts`'s.
|
|
4
|
+
|
|
5
|
+
import { ByteWriter, epochMsToPgTimestamp } from './pg-bytes';
|
|
6
|
+
|
|
7
|
+
/** `r` — the standby status update, the only frontend message a walsender listens for. */
|
|
8
|
+
const STANDBY_STATUS = 0x72;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `<commit lsn><position in transaction>`, both zero-padded hex, so string order is stream order.
|
|
12
|
+
*
|
|
13
|
+
* The commit lsn alone is not enough: every row of one transaction shares it, and the replicator
|
|
14
|
+
* drops anything that does not strictly increase — a five-row insert would deliver one row. The
|
|
15
|
+
* per-record WAL position is not enough either: logical decoding emits *transactions* in commit
|
|
16
|
+
* order, so a later-committing transaction can carry lower record positions than an earlier one.
|
|
17
|
+
* The pair is monotonic in the order changes are delivered and identical on replay, which is what
|
|
18
|
+
* makes an at-least-once redelivery deduplicate instead of duplicating.
|
|
19
|
+
*/
|
|
20
|
+
export const changeLsn = (commitLsn: bigint, sequence: number): string =>
|
|
21
|
+
commitLsn.toString(16).padStart(16, '0') + sequence.toString(16).padStart(8, '0');
|
|
22
|
+
|
|
23
|
+
/** The commit position inside a change lsn — where a resume asks the server to restart. */
|
|
24
|
+
export const commitPositionOf = (lsn: string): bigint => BigInt(`0x${lsn.slice(0, 16) || '0'}`);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Written, flushed and applied all at `position`. `requestReply` asks the walsender to answer at
|
|
28
|
+
* once with a keepalive — what lets the stream measure silence (`pg-replication-liveness.ts`).
|
|
29
|
+
*/
|
|
30
|
+
export const standbyStatus = (
|
|
31
|
+
position: bigint,
|
|
32
|
+
epochMs: number,
|
|
33
|
+
requestReply: boolean,
|
|
34
|
+
): Uint8Array =>
|
|
35
|
+
new ByteWriter(34)
|
|
36
|
+
.uint8(STANDBY_STATUS)
|
|
37
|
+
.int64(position)
|
|
38
|
+
.int64(position)
|
|
39
|
+
.int64(position)
|
|
40
|
+
.int64(epochMsToPgTimestamp(epochMs))
|
|
41
|
+
.uint8(requestReply ? 1 : 0)
|
|
42
|
+
.finish();
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
// `after` could not carry. Split out of `pg-replication.ts`, which decides ordering and nothing else.
|
|
4
4
|
|
|
5
5
|
import { ReplicationProtocolError } from './errors';
|
|
6
|
-
import { isRow, type
|
|
6
|
+
import { isRow, type LiveRow } from './json';
|
|
7
7
|
import { entityRow, omittedProperties } from './pg-entity-row';
|
|
8
8
|
import type { PhysicalRow } from './pg-values';
|
|
9
9
|
import type { PgRelation } from './pgoutput';
|
|
10
10
|
|
|
11
11
|
export interface ReplicatedImages {
|
|
12
|
-
readonly before:
|
|
13
|
-
readonly after:
|
|
12
|
+
readonly before: LiveRow | null;
|
|
13
|
+
readonly after: LiveRow | null;
|
|
14
14
|
/** Properties missing from `after` because Postgres logged no value for them. Usually empty. */
|
|
15
15
|
readonly omitted: readonly string[];
|
|
16
16
|
}
|
|
@@ -59,12 +59,12 @@ function toRow(
|
|
|
59
59
|
relation: PgRelation,
|
|
60
60
|
physical: PhysicalRow | null,
|
|
61
61
|
image: 'before' | 'after',
|
|
62
|
-
):
|
|
62
|
+
): LiveRow | null {
|
|
63
63
|
if (physical === null) return null;
|
|
64
64
|
const row = entityRow(relation, physical, image);
|
|
65
65
|
// A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
|
|
66
66
|
// so the same table would otherwise identify small rows by number and large ones by string.
|
|
67
|
-
// `
|
|
67
|
+
// `LiveRow.id`, `RowPatch.id` and every cursor are text: the identity is normalised once, here.
|
|
68
68
|
const id = row['id'];
|
|
69
69
|
if (typeof id === 'number' && Number.isSafeInteger(id)) row['id'] = String(id);
|
|
70
70
|
if (isRow(row)) return row;
|
|
@@ -76,7 +76,7 @@ function toRow(
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** The tenant, hoisted out of the row so fanout filters without parsing it. */
|
|
79
|
-
export const tenantOf = (row:
|
|
79
|
+
export const tenantOf = (row: LiveRow | null): string | null => {
|
|
80
80
|
const orgId = row?.['orgId'];
|
|
81
81
|
return typeof orgId === 'string' ? orgId : null;
|
|
82
82
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Single responsibility: the shape of what a replication stream reports about itself — what
|
|
2
|
+
// `changefeed.ts` surfaces as `stats()` and `/readyz` reads. Counts and the death reason only.
|
|
3
|
+
|
|
4
|
+
export interface ReplicationStreamStats {
|
|
5
|
+
readonly delivered: number;
|
|
6
|
+
/** Rows for a table outside the entity list — the publication is wider than the app is. */
|
|
7
|
+
readonly skipped: number;
|
|
8
|
+
/** Rows replayed from before the resume position, dropped so `onChange` sees each one once. */
|
|
9
|
+
readonly replayed: number;
|
|
10
|
+
/**
|
|
11
|
+
* Changes delivered from a relation whose replica identity is not FULL — so the `before` row is
|
|
12
|
+
* the key columns alone (or absent), and the live matcher's "did this row leave the result set"
|
|
13
|
+
* is decided on a partial row. `X_LIVE_REPLICA_IDENTITY` warns about the CONFIGURATION once at
|
|
14
|
+
* preflight; this counts the decisions it actually cost, which is the half a running node can
|
|
15
|
+
* be alerted on. Inserts never count: there is no `before` to be partial.
|
|
16
|
+
*/
|
|
17
|
+
readonly partialBefore: number;
|
|
18
|
+
/**
|
|
19
|
+
* Standby-status writes that have failed in a row without one landing in between. It is the half
|
|
20
|
+
* of a broken stream the delivery count cannot show: the read side goes on delivering while
|
|
21
|
+
* `confirmed_flush_lsn` stops advancing, so WAL accumulates on the primary with nothing else
|
|
22
|
+
* reporting it. An interval whose update is still unsettled from the last one counts too: a
|
|
23
|
+
* write parked on a socket that never drains is never a rejection. Reset by the first that lands.
|
|
24
|
+
*/
|
|
25
|
+
readonly confirmFailures: number;
|
|
26
|
+
/** Why the pump stopped, or `null` while live. Recorded here and ANNOUNCED through `onEnd`. */
|
|
27
|
+
readonly failure: string | null;
|
|
28
|
+
}
|
package/src/pg-replication.ts
CHANGED
|
@@ -6,16 +6,26 @@
|
|
|
6
6
|
import { type Clock, finiteOption, logger, renderThrowable, systemClock } from '@ultimat3/core';
|
|
7
7
|
import type { ChangeEvent, ChangeOp, PostgresChangeFeedOptions } from './changefeed';
|
|
8
8
|
import { ReplicationProtocolError } from './errors';
|
|
9
|
-
import { ByteReader
|
|
10
|
-
import { PgConnection } from './pg-connection';
|
|
9
|
+
import { ByteReader } from './pg-bytes';
|
|
10
|
+
import type { PgConnection } from './pg-connection';
|
|
11
11
|
import { assertIdentifier } from './pg-identifier';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { dialReplication, type SlotGap } from './pg-replication-dial';
|
|
13
|
+
import {
|
|
14
|
+
type ConfirmAttempt,
|
|
15
|
+
StreamLiveness,
|
|
16
|
+
silenceReason,
|
|
17
|
+
unsettledReason,
|
|
18
|
+
} from './pg-replication-liveness';
|
|
19
|
+
import { changeLsn, standbyStatus } from './pg-replication-position';
|
|
14
20
|
import { replicatedImages, tenantOf } from './pg-replication-row';
|
|
15
|
-
import {
|
|
21
|
+
import type { ReplicationStreamStats } from './pg-replication-stats';
|
|
16
22
|
import type { PhysicalRow } from './pg-values';
|
|
17
23
|
import { keyedWrite, PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
|
|
18
24
|
|
|
25
|
+
// Moved next door; their importers (`server.ts`, `changefeed.ts`, the tests) keep this path.
|
|
26
|
+
export { changeLsn, commitPositionOf } from './pg-replication-position';
|
|
27
|
+
export type { ReplicationStreamStats } from './pg-replication-stats';
|
|
28
|
+
|
|
19
29
|
const DEFAULT_STATUS_INTERVAL_MS = 10_000;
|
|
20
30
|
|
|
21
31
|
/**
|
|
@@ -28,34 +38,6 @@ const DEFAULT_STATUS_INTERVAL_MS = 10_000;
|
|
|
28
38
|
*/
|
|
29
39
|
const MAX_CONFIRM_FAILURES = 3;
|
|
30
40
|
|
|
31
|
-
/** `r` — the standby status update, the only frontend message a walsender listens for. */
|
|
32
|
-
const STANDBY_STATUS = 0x72;
|
|
33
|
-
|
|
34
|
-
export interface ReplicationStreamStats {
|
|
35
|
-
readonly delivered: number;
|
|
36
|
-
/** Rows for a table outside the entity list — the publication is wider than the app is. */
|
|
37
|
-
readonly skipped: number;
|
|
38
|
-
/** Rows replayed from before the resume position, dropped so `onChange` sees each one once. */
|
|
39
|
-
readonly replayed: number;
|
|
40
|
-
/**
|
|
41
|
-
* Changes delivered from a relation whose replica identity is not FULL — so the `before` row is
|
|
42
|
-
* the key columns alone (or absent), and the live matcher's "did this row leave the result set"
|
|
43
|
-
* is decided on a partial row. `X_LIVE_REPLICA_IDENTITY` warns about the CONFIGURATION once at
|
|
44
|
-
* preflight; this counts the decisions it actually cost, which is the half a running node can
|
|
45
|
-
* be alerted on. Inserts never count: there is no `before` to be partial.
|
|
46
|
-
*/
|
|
47
|
-
readonly partialBefore: number;
|
|
48
|
-
/**
|
|
49
|
-
* Standby-status writes that have failed in a row without one landing in between. It is the half
|
|
50
|
-
* of a broken stream the delivery count cannot show: the read side goes on delivering while
|
|
51
|
-
* `confirmed_flush_lsn` stops advancing, so WAL accumulates on the primary with nothing else
|
|
52
|
-
* reporting it. Reset by the first confirm that lands.
|
|
53
|
-
*/
|
|
54
|
-
readonly confirmFailures: number;
|
|
55
|
-
/** Why the pump stopped, or `null` while live. Recorded here and ANNOUNCED through `onEnd`. */
|
|
56
|
-
readonly failure: string | null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
41
|
interface Transaction {
|
|
60
42
|
readonly commitLsn: bigint;
|
|
61
43
|
readonly commitAt: number;
|
|
@@ -66,27 +48,13 @@ interface Transaction {
|
|
|
66
48
|
write?: string | undefined;
|
|
67
49
|
}
|
|
68
50
|
|
|
69
|
-
/**
|
|
70
|
-
* `<commit lsn><position in transaction>`, both zero-padded hex, so string order is stream order.
|
|
71
|
-
*
|
|
72
|
-
* The commit lsn alone is not enough: every row of one transaction shares it, and the replicator
|
|
73
|
-
* drops anything that does not strictly increase — a five-row insert would deliver one row. The
|
|
74
|
-
* per-record WAL position is not enough either: logical decoding emits *transactions* in commit
|
|
75
|
-
* order, so a later-committing transaction can carry lower record positions than an earlier one.
|
|
76
|
-
* The pair is monotonic in the order changes are delivered and identical on replay, which is what
|
|
77
|
-
* makes an at-least-once redelivery deduplicate instead of duplicating.
|
|
78
|
-
*/
|
|
79
|
-
export const changeLsn = (commitLsn: bigint, sequence: number): string =>
|
|
80
|
-
commitLsn.toString(16).padStart(16, '0') + sequence.toString(16).padStart(8, '0');
|
|
81
|
-
|
|
82
|
-
/** The commit position inside a change lsn — where a resume asks the server to restart. */
|
|
83
|
-
export const commitPositionOf = (lsn: string): bigint => BigInt(`0x${lsn.slice(0, 16) || '0'}`);
|
|
84
|
-
|
|
85
51
|
export interface ReplicationStreamHandlers {
|
|
86
52
|
readonly from?: string | undefined;
|
|
87
53
|
onChange(event: ChangeEvent): void | Promise<void>;
|
|
88
54
|
/** Called once when the pump dies on its own; never for a `stop()`. */
|
|
89
55
|
readonly onEnd?: ((reason: string) => void) | undefined;
|
|
56
|
+
/** The stream is continuous no more — its slot was recreated under `from` — so re-read all. */
|
|
57
|
+
readonly onResync?: ((reason: string) => void) | undefined;
|
|
90
58
|
}
|
|
91
59
|
|
|
92
60
|
/**
|
|
@@ -114,6 +82,10 @@ export class PgReplicationStream {
|
|
|
114
82
|
#partialBefore = 0;
|
|
115
83
|
#confirmFailures = 0;
|
|
116
84
|
#failure: string | null = null;
|
|
85
|
+
readonly #liveness = new StreamLiveness();
|
|
86
|
+
/** A slot created under `from` by a dial that has not yet got through (`SlotGap`). */
|
|
87
|
+
readonly #slotGap: SlotGap = { createdUnder: null };
|
|
88
|
+
#intervalMs = DEFAULT_STATUS_INTERVAL_MS;
|
|
117
89
|
#onEnd: ((reason: string) => void) | undefined;
|
|
118
90
|
|
|
119
91
|
constructor(options: PostgresChangeFeedOptions) {
|
|
@@ -145,63 +117,38 @@ export class PgReplicationStream {
|
|
|
145
117
|
/** Resolves once the stream is live. Delivery continues on the pump until `stop()`. */
|
|
146
118
|
async start(handlers: ReplicationStreamHandlers): Promise<void> {
|
|
147
119
|
if (this.#running) return;
|
|
148
|
-
// `#pump` is the previous run's
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// `#pump` with its own and leave the old walsender holding the slot — the next `stop()` would
|
|
152
|
-
// await only the new pump and report a released slot to a supervisor whose next process then
|
|
153
|
-
// collides with one that is still `active`. Waiting for it is waiting for the prior teardown.
|
|
120
|
+
// `#pump` is the previous run's TERMINAL CLEANUP (`#die` awaits `close()`): dialling before it
|
|
121
|
+
// settles leaves the old walsender holding the slot while `stop()` reports it released. A
|
|
122
|
+
// destroyed run has no cleanup left to wait for (`#letPumpGo`).
|
|
154
123
|
const previous = this.#pump;
|
|
155
124
|
this.#pump = null;
|
|
156
125
|
if (previous !== null) await previous;
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const stream = await (this.#options.stream ?? bunPgStream)(target);
|
|
161
|
-
const connection = await PgConnection.open({
|
|
162
|
-
stream,
|
|
163
|
-
user: target.user,
|
|
164
|
-
password: target.password,
|
|
165
|
-
database: target.database,
|
|
166
|
-
replication: 'database',
|
|
167
|
-
applicationName: `ultimate-replicator:${slot}`,
|
|
168
|
-
rng: this.#options.rng,
|
|
169
|
-
ssl: target.ssl,
|
|
170
|
-
});
|
|
126
|
+
const names = { slot: this.#slot, publication: this.#publication, entities: this.#entities };
|
|
127
|
+
const dialed = await dialReplication(this.#options, names, handlers.from, this.#slotGap);
|
|
128
|
+
const connection = dialed.connection;
|
|
171
129
|
this.#connection = connection;
|
|
172
|
-
|
|
173
|
-
const full = this.#options.fullIdentityTables ?? paramsChannelTables();
|
|
174
|
-
await preflight(connection, slot, publication, this.#entities, new Set(full));
|
|
175
|
-
const from = handlers.from;
|
|
176
|
-
this.#confirmed = from === undefined ? 0n : commitPositionOf(from);
|
|
177
|
-
await connection.startCopyBoth(
|
|
178
|
-
`START_REPLICATION SLOT ${slot} LOGICAL ${printLsn(this.#confirmed)} ` +
|
|
179
|
-
`(proto_version '1', publication_names '${publication}', messages 'true')`,
|
|
180
|
-
);
|
|
181
|
-
} catch (failure) {
|
|
182
|
-
// The dial failure is the one that explains the boot, so a teardown that also failed must
|
|
183
|
-
// not replace it — `stop()` has released everything either way by the time it rethrows.
|
|
184
|
-
await this.stop().catch(() => undefined);
|
|
185
|
-
throw failure;
|
|
186
|
-
}
|
|
130
|
+
this.#confirmed = dialed.confirmed;
|
|
187
131
|
this.#running = true;
|
|
188
132
|
// A restart that kept the last death in `stats()` would report a live stream as failed.
|
|
189
133
|
this.#failure = null;
|
|
190
134
|
this.#confirmFailures = 0;
|
|
191
135
|
this.#onEnd = handlers.onEnd;
|
|
192
|
-
this.#
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
),
|
|
136
|
+
this.#liveness.reset();
|
|
137
|
+
// A confirm the last run parked on a dead socket never settles: queued behind it, every
|
|
138
|
+
// confirm of this run would park too, and the slot would never be confirmed again.
|
|
139
|
+
this.#writing = Promise.resolve();
|
|
140
|
+
this.#intervalMs = finiteOption(
|
|
141
|
+
'the replication stream',
|
|
142
|
+
'statusIntervalMs',
|
|
143
|
+
this.#options.statusIntervalMs ?? DEFAULT_STATUS_INTERVAL_MS,
|
|
201
144
|
);
|
|
145
|
+
this.#timer = setInterval(() => this.#tick(), this.#intervalMs);
|
|
202
146
|
// A pending timer must not be what keeps `x dev` alive after the app is done with it.
|
|
203
147
|
this.#timer.unref?.();
|
|
204
|
-
this.#pump = this.#drain(connection, handlers);
|
|
148
|
+
this.#pump = this.#drain(connection, { ...handlers, from: dialed.from });
|
|
149
|
+
// Before the first change of the new slot can reach anyone: every reader re-reads NOW, rather
|
|
150
|
+
// than a quiet table staying stale until its next write.
|
|
151
|
+
if (dialed.resync !== null) handlers.onResync?.(dialed.resync);
|
|
205
152
|
}
|
|
206
153
|
|
|
207
154
|
/**
|
|
@@ -255,6 +202,15 @@ export class PgReplicationStream {
|
|
|
255
202
|
const connection = this.#connection;
|
|
256
203
|
this.#connection = null;
|
|
257
204
|
connection?.destroy();
|
|
205
|
+
this.#letPumpGo();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* After a DESTROY the teardown is done, but the pump may be parked on a read a half-open socket
|
|
210
|
+
* never ends. Nothing waits on it; `#drain`'s connection fence keeps it off the next run.
|
|
211
|
+
*/
|
|
212
|
+
#letPumpGo(): void {
|
|
213
|
+
this.#pump = null;
|
|
258
214
|
}
|
|
259
215
|
|
|
260
216
|
/**
|
|
@@ -265,7 +221,7 @@ export class PgReplicationStream {
|
|
|
265
221
|
* and a `#connection` the next `start()` overwrites instead of releasing. A `stop()` that got
|
|
266
222
|
* here first owns the connection, and its exit is an orderly one, not a failure.
|
|
267
223
|
*/
|
|
268
|
-
async #die(reason: string): Promise<void> {
|
|
224
|
+
async #die(reason: string, goodbye: 'close' | 'destroy' = 'close'): Promise<void> {
|
|
269
225
|
if (!this.#running) return;
|
|
270
226
|
this.#running = false;
|
|
271
227
|
this.#failure = reason;
|
|
@@ -274,8 +230,14 @@ export class PgReplicationStream {
|
|
|
274
230
|
this.#connection = null;
|
|
275
231
|
// The loop records, reports and ends rather than throwing into a promise nothing awaits.
|
|
276
232
|
logger.error('replication stream ended', { slot: this.#options.slot, error: reason });
|
|
233
|
+
// A liveness death DESTROYS: the far end is not answering, and a `Terminate` written into it
|
|
234
|
+
// is a write nothing settles — `#die` would park there and never reach `onEnd`.
|
|
235
|
+
if (goodbye === 'destroy') {
|
|
236
|
+
connection?.destroy();
|
|
237
|
+
this.#letPumpGo();
|
|
238
|
+
}
|
|
277
239
|
try {
|
|
278
|
-
await connection?.close();
|
|
240
|
+
if (goodbye === 'close') await connection?.close();
|
|
279
241
|
} catch {
|
|
280
242
|
// The socket is already unusable and `failure` above is the report that matters — a
|
|
281
243
|
// goodbye that throws must not become the rejection `#drain` promised never to produce.
|
|
@@ -300,7 +262,12 @@ export class PgReplicationStream {
|
|
|
300
262
|
async #drain(connection: PgConnection, handlers: ReplicationStreamHandlers): Promise<void> {
|
|
301
263
|
try {
|
|
302
264
|
for (;;) {
|
|
265
|
+
this.#liveness.awaitingRead();
|
|
303
266
|
const payload = await connection.nextCopyData();
|
|
267
|
+
// A run that was let go — `stop()`, `abandon()`, a liveness death — ends here: nothing it
|
|
268
|
+
// reads afterwards is this stream's, and its `#die` would end the NEXT run.
|
|
269
|
+
if (this.#connection !== connection) return;
|
|
270
|
+
this.#liveness.heard();
|
|
304
271
|
if (payload === undefined) {
|
|
305
272
|
// The walsender ended the copy. However politely it said so, nothing reads the slot
|
|
306
273
|
// again until something restarts this — which is a failure, not a shutdown.
|
|
@@ -330,6 +297,7 @@ export class PgReplicationStream {
|
|
|
330
297
|
}
|
|
331
298
|
}
|
|
332
299
|
} catch (failure) {
|
|
300
|
+
if (this.#connection !== connection) return;
|
|
333
301
|
await this.#die(renderThrowable(failure));
|
|
334
302
|
}
|
|
335
303
|
}
|
|
@@ -430,25 +398,45 @@ export class PgReplicationStream {
|
|
|
430
398
|
this.#delivered += 1;
|
|
431
399
|
}
|
|
432
400
|
|
|
401
|
+
/** One status interval: the read deadline, then the confirm (`pg-replication-liveness.ts`). */
|
|
402
|
+
#tick(): void {
|
|
403
|
+
// Silence first: a half-open socket accepts every write, so no confirm ever fails on one.
|
|
404
|
+
if (this.#liveness.silentTooLong()) {
|
|
405
|
+
void this.#die(silenceReason(this.#intervalMs), 'destroy');
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
void this.#confirmOnTimer();
|
|
409
|
+
}
|
|
410
|
+
|
|
433
411
|
/**
|
|
434
|
-
* The TIMER's confirm, and the one call that may not reject
|
|
435
|
-
* rejection
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* otherwise clean shutdown.
|
|
439
|
-
*
|
|
440
|
-
* Worse than the crash was the silence before it: `stats().failure` stayed `null`, so `/readyz`
|
|
441
|
-
* reported the replicator live while `confirmed_flush_lsn` stopped advancing and WAL piled up on
|
|
442
|
-
* the primary. A run of failures is now a death, the same way `#drain`'s catch is — the four
|
|
443
|
-
* things `#die` owns go together or not at all.
|
|
412
|
+
* The TIMER's confirm, and the one call that may not reject: nothing awaits it, and an unhandled
|
|
413
|
+
* rejection ends the Bun process uncoded. A run of failures — rejected, or still unsettled at the
|
|
414
|
+
* next interval — is a death instead, so `stats().failure` and `/readyz` see a slot that is not
|
|
415
|
+
* being confirmed while WAL piles up on the primary.
|
|
444
416
|
*/
|
|
445
417
|
async #confirmOnTimer(): Promise<void> {
|
|
418
|
+
const connection = this.#connection;
|
|
419
|
+
if (connection === null) return;
|
|
420
|
+
const attempt: ConfirmAttempt | null = this.#liveness.begin();
|
|
421
|
+
if (attempt === null) {
|
|
422
|
+
// The last interval's write is still on the wire: that is this interval's failure.
|
|
423
|
+
this.#confirmFailures += 1;
|
|
424
|
+
const unsettled = this.#confirmFailures;
|
|
425
|
+
logger.warn('replication confirm unsettled', { slot: this.#options.slot, unsettled });
|
|
426
|
+
if (unsettled >= MAX_CONFIRM_FAILURES) {
|
|
427
|
+
await this.#die(unsettledReason(unsettled, this.#intervalMs), 'destroy');
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
446
431
|
try {
|
|
447
|
-
await this.#confirm();
|
|
432
|
+
await this.#confirm(connection, true);
|
|
433
|
+
if (this.#connection !== connection) return;
|
|
448
434
|
// Consecutive, not cumulative: one confirm that lands means the walsender is being told
|
|
449
435
|
// where we are, and a lifetime count would eventually kill a healthy stream.
|
|
450
436
|
this.#confirmFailures = 0;
|
|
451
437
|
} catch (failure) {
|
|
438
|
+
// A write from a run that already ended is not this run's failure.
|
|
439
|
+
if (this.#connection !== connection) return;
|
|
452
440
|
this.#confirmFailures += 1;
|
|
453
441
|
logger.warn('replication confirm failed', {
|
|
454
442
|
slot: this.#options.slot,
|
|
@@ -456,12 +444,13 @@ export class PgReplicationStream {
|
|
|
456
444
|
error: renderThrowable(failure),
|
|
457
445
|
});
|
|
458
446
|
const consecutive = this.#confirmFailures;
|
|
459
|
-
if (consecutive >= MAX_CONFIRM_FAILURES)
|
|
447
|
+
if (consecutive >= MAX_CONFIRM_FAILURES)
|
|
460
448
|
await this.#die(
|
|
461
449
|
`${consecutive} standby status updates failed in a row — ` +
|
|
462
450
|
`the slot is not being confirmed: ${renderThrowable(failure)}`,
|
|
463
451
|
);
|
|
464
|
-
|
|
452
|
+
} finally {
|
|
453
|
+
this.#liveness.settle(attempt);
|
|
465
454
|
}
|
|
466
455
|
}
|
|
467
456
|
|
|
@@ -472,23 +461,15 @@ export class PgReplicationStream {
|
|
|
472
461
|
* It still RAISES: `stop()` and the keepalive reply both await it and both need the answer. Only
|
|
473
462
|
* the timer, which awaits nothing, goes through `#confirmOnTimer` above.
|
|
474
463
|
*/
|
|
475
|
-
async #confirm(
|
|
464
|
+
async #confirm(
|
|
465
|
+
connection: PgConnection | null = this.#connection,
|
|
466
|
+
requestReply = false,
|
|
467
|
+
): Promise<void> {
|
|
476
468
|
if (connection === null || !connection.inCopyBoth) return;
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
.int64(position)
|
|
482
|
-
.int64(position)
|
|
483
|
-
.int64(position)
|
|
484
|
-
.int64(at)
|
|
485
|
-
.uint8(0)
|
|
486
|
-
.finish();
|
|
487
|
-
// The chain SERIALIZES writes; it does not decide them. Putting the rejection handler on the
|
|
488
|
-
// chain itself meant the confirm after a failed one resolved without writing anything —
|
|
489
|
-
// `.then(send, () => undefined)` runs the second handler and hands back its `undefined`, so
|
|
490
|
-
// every other standby status update after the first failure was a no-op that reported success.
|
|
491
|
-
// A run of failures could then never be seen, because the run never got past one.
|
|
469
|
+
const payload = standbyStatus(this.#confirmed, this.#clock.now().getTime(), requestReply);
|
|
470
|
+
// The chain SERIALIZES writes; it does not decide them. A rejection handler on the chain itself
|
|
471
|
+
// (`.then(send, () => undefined)`) made every update after a failed one a no-op reporting
|
|
472
|
+
// success, so a run of failures could never be seen.
|
|
492
473
|
const attempt = this.#writing.then(() => connection.sendCopyData(payload));
|
|
493
474
|
// What the NEXT caller queues behind is this attempt settled either way; what THIS caller
|
|
494
475
|
// awaits is the attempt itself, because it is the only one entitled to its outcome.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Single responsibility: wait, bounded, for a replication slot's previous walsender to let go of it
|
|
2
|
+
// before `START_REPLICATION` asks for it. A goodbye is not a release: the old backend frees the
|
|
3
|
+
// slot on its way out, after the socket said `Terminate`, so a dial right behind it was refused.
|
|
4
|
+
|
|
5
|
+
import type { PgConnection } from './pg-connection';
|
|
6
|
+
import { assertIdentifier } from './pg-identifier';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How many times the slot is asked again, each ask holding the SERVER for `SLOT_POLL_SECONDS` —
|
|
10
|
+
* ~5 s in all. Long enough for any backend that was told goodbye, or whose socket closed, to exit
|
|
11
|
+
* (milliseconds); short enough that a slot held by a genuine second replicator still fails the boot
|
|
12
|
+
* with postgres' own 55006 and its fix, instead of hanging it. A walsender that never heard its
|
|
13
|
+
* client go (no FIN) outlives this by its keepalive, ~25 s; the replicator's restart loop covers it.
|
|
14
|
+
* A count, not a clock: the polls are the server's time, so a frozen test clock cannot stall it.
|
|
15
|
+
*/
|
|
16
|
+
export const SLOT_RELEASE_POLLS = 100;
|
|
17
|
+
const SLOT_POLL_SECONDS = 0.05;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns once the slot has no `active_pid` (or no longer exists), or after `SLOT_RELEASE_POLLS`
|
|
21
|
+
* asks — then `START_REPLICATION` is left to refuse with 55006, which names the fix. `activePid` is
|
|
22
|
+
* what preflight already read, so a slot nobody holds costs no statement at all. Each ask reads,
|
|
23
|
+
* then sleeps on the server: no client timer, and nothing between the asks for a test to move.
|
|
24
|
+
*/
|
|
25
|
+
export async function awaitSlotReleased(
|
|
26
|
+
connection: PgConnection,
|
|
27
|
+
slot: string,
|
|
28
|
+
activePid: string | null | undefined,
|
|
29
|
+
): Promise<boolean> {
|
|
30
|
+
if (activePid === null || activePid === undefined) return true;
|
|
31
|
+
assertIdentifier('slot', slot);
|
|
32
|
+
for (let poll = 0; poll < SLOT_RELEASE_POLLS; poll += 1) {
|
|
33
|
+
const [row] = await connection.query(
|
|
34
|
+
`SELECT active_pid, pg_sleep(${SLOT_POLL_SECONDS}) FROM pg_replication_slots ` +
|
|
35
|
+
`WHERE slot_name = '${slot}'`,
|
|
36
|
+
);
|
|
37
|
+
if (row === undefined || row[0] === null || row[0] === undefined) return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
package/src/policy-gate.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
11
11
|
import { guardQuery, QueryDeniedError, type QueryPolicy, type QuerySubject } from '@ultimat3/query';
|
|
12
|
-
import type { JsonValue,
|
|
12
|
+
import type { JsonValue, LiveRow } from './json';
|
|
13
13
|
|
|
14
14
|
export interface GateOptions {
|
|
15
15
|
/** Query name, for the denial reason and the policy trace. */
|
|
@@ -34,7 +34,7 @@ export function authorizeWithPolicy(
|
|
|
34
34
|
* query. The row travels as `row`, the same field an HTTP or job row rule reads: a predicate is
|
|
35
35
|
* written once as `({ actor, row }) => …` and works on every surface.
|
|
36
36
|
*/
|
|
37
|
-
export function visibleWithPolicy<R extends
|
|
37
|
+
export function visibleWithPolicy<R extends LiveRow = LiveRow>(
|
|
38
38
|
policy: QueryPolicy,
|
|
39
39
|
options: GateOptions,
|
|
40
40
|
): (args: { actor: Actor | null; row: R; input: JsonValue }) => Promise<boolean> {
|
package/src/query-window.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { LiveReadPhase } from '@ultimat3/query';
|
|
6
6
|
import { WindowReadTimeoutError } from './errors';
|
|
7
|
-
import type { JsonValue,
|
|
7
|
+
import type { JsonValue, LiveRow } from './json';
|
|
8
8
|
import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
|
|
9
9
|
import type { IncrementalMatcher, SubscriptionShape } from './matcher-bridge';
|
|
10
10
|
import type { Scheduler } from './thundering-herd';
|
|
@@ -39,7 +39,7 @@ export interface QueryEntry {
|
|
|
39
39
|
/** Told to the client on every snapshot: the identity scope its rows belong under. */
|
|
40
40
|
readonly rowEntity: string | null;
|
|
41
41
|
/** The record key a row travels under; `null` = its `id` (a plain table). */
|
|
42
|
-
readonly rowKey: ((row:
|
|
42
|
+
readonly rowKey: ((row: LiveRow) => string) | null;
|
|
43
43
|
readonly shape: SubscriptionShape;
|
|
44
44
|
readonly matcher: IncrementalMatcher;
|
|
45
45
|
readonly subscribers: Map<string, LiveSubscription>;
|
|
@@ -47,7 +47,7 @@ export interface QueryEntry {
|
|
|
47
47
|
* The shared, *pre-policy* result window. One per query id, bounded by the query's `limit`, and
|
|
48
48
|
* the reason the matcher can run once for N subscribers: the read is shared, the authz is not.
|
|
49
49
|
*/
|
|
50
|
-
rows: readonly
|
|
50
|
+
rows: readonly LiveRow[];
|
|
51
51
|
lsn: string;
|
|
52
52
|
/**
|
|
53
53
|
* This window is known to have missed at least one change, so patching it would compound the
|
|
@@ -197,7 +197,7 @@ export async function fillWindow(entry: QueryEntry): Promise<FilledWindow> {
|
|
|
197
197
|
* filled — its patch history is still true, and flooring it would evict every other subscriber's.
|
|
198
198
|
*/
|
|
199
199
|
export interface FilledWindow {
|
|
200
|
-
readonly rows: readonly
|
|
200
|
+
readonly rows: readonly LiveRow[];
|
|
201
201
|
readonly lsn: string;
|
|
202
202
|
readonly landed: 'first' | 'forced' | null;
|
|
203
203
|
}
|