@ultimat3/realtime 30.9.0 → 31.1.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/offline-queue.ts
CHANGED
|
@@ -10,72 +10,22 @@
|
|
|
10
10
|
// `ack`/`fail` frame settles it, or a lost connection returns it to the queue.
|
|
11
11
|
|
|
12
12
|
import type { ActionPin } from '@ultimat3/core/page';
|
|
13
|
-
import { renderThrowable, stringField } from '@ultimat3/core/page';
|
|
13
|
+
import { finiteCount, renderThrowable, stringField } from '@ultimat3/core/page';
|
|
14
14
|
import type { JsonValue } from './json';
|
|
15
|
-
import {
|
|
15
|
+
import type { MutationStatus, QueuedMutation, QueueState, QueueStore } from './offline-queue-store';
|
|
16
|
+
import { OfflineQueueAbandonedError, OfflineQueueFullError } from './page-errors';
|
|
16
17
|
import type { WireError } from './sync-protocol';
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
readonly http?: ActionPin | undefined;
|
|
29
|
-
readonly enqueuedAt: number;
|
|
30
|
-
attempts: number;
|
|
31
|
-
status: MutationStatus;
|
|
32
|
-
error: WireError | null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface QueueState {
|
|
36
|
-
readonly mutations: readonly QueuedMutation[];
|
|
37
|
-
readonly nextSeq: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* One change to the durable queue: these entries written BY KEY, these keys deleted, and the
|
|
42
|
-
* sequence floor. Never the whole queue — two tabs of one user share the store, and a whole-queue
|
|
43
|
-
* save let the last tab to save erase the other's queued write.
|
|
44
|
-
*/
|
|
45
|
-
export interface QueueChange {
|
|
46
|
-
readonly puts: readonly QueuedMutation[];
|
|
47
|
-
readonly deletes: readonly string[];
|
|
48
|
-
readonly nextSeq: number;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Durability seam: IndexedDB in the browser (`page-outbox.ts`), memory in tests. */
|
|
52
|
-
export interface QueueStore {
|
|
53
|
-
load(): Promise<QueueState>;
|
|
54
|
-
write(change: QueueChange): Promise<void>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export class MemoryQueueStore implements QueueStore {
|
|
58
|
-
readonly #mutations = new Map<string, QueuedMutation>();
|
|
59
|
-
#nextSeq = 1;
|
|
60
|
-
|
|
61
|
-
async load(): Promise<QueueState> {
|
|
62
|
-
return {
|
|
63
|
-
mutations: [...this.#mutations.values()].map((m) => ({ ...m })),
|
|
64
|
-
nextSeq: this.#nextSeq,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async write(change: QueueChange): Promise<void> {
|
|
69
|
-
for (const key of change.deletes) this.#mutations.delete(key);
|
|
70
|
-
for (const mutation of change.puts) this.#mutations.set(mutation.key, { ...mutation });
|
|
71
|
-
this.#nextSeq = Math.max(this.#nextSeq, change.nextSeq);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** The one way to build the in-process queue store; the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
|
|
76
|
-
export function memoryQueueStore(): MemoryQueueStore {
|
|
77
|
-
return new MemoryQueueStore();
|
|
78
|
-
}
|
|
19
|
+
// The shapes and the memory store moved next door; their importers keep this path.
|
|
20
|
+
export {
|
|
21
|
+
MemoryQueueStore,
|
|
22
|
+
type MutationStatus,
|
|
23
|
+
memoryQueueStore,
|
|
24
|
+
type QueueChange,
|
|
25
|
+
type QueuedMutation,
|
|
26
|
+
type QueueState,
|
|
27
|
+
type QueueStore,
|
|
28
|
+
} from './offline-queue-store';
|
|
79
29
|
|
|
80
30
|
export interface DrainReport {
|
|
81
31
|
readonly sent: number;
|
|
@@ -87,9 +37,25 @@ export interface DrainReport {
|
|
|
87
37
|
|
|
88
38
|
export type MutationSender = (mutation: QueuedMutation) => Promise<void>;
|
|
89
39
|
|
|
40
|
+
export interface OfflineQueueOptions {
|
|
41
|
+
/**
|
|
42
|
+
* The most writes held at once. Past it a NEW key is refused (`X_OFFLINE_QUEUE_FULL`); a key
|
|
43
|
+
* already held still collapses, and nothing queued is ever dropped. Absent: no ceiling.
|
|
44
|
+
*/
|
|
45
|
+
readonly maxQueued?: number | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
90
48
|
export class OfflineQueue {
|
|
91
49
|
readonly #store: QueueStore;
|
|
92
|
-
#
|
|
50
|
+
readonly #maxQueued: number | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* By key, HELD in sequence order: a key is a lookup and the queue reads in seq order with no sort.
|
|
53
|
+
* A scan per enqueue and ack plus a sort per `pending()` made a long offline session quadratic.
|
|
54
|
+
* Written through `#seat` and `#append` only, which are what keep the order true.
|
|
55
|
+
*/
|
|
56
|
+
#mutations = new Map<string, QueuedMutation>();
|
|
57
|
+
/** The highest seq held: an append above it keeps the order without a sort. */
|
|
58
|
+
#tailSeq = 0;
|
|
93
59
|
#nextSeq = 1;
|
|
94
60
|
#collapsed = 0;
|
|
95
61
|
/** The drain lane: one pass at a time, in call order. See `drain`. */
|
|
@@ -106,9 +72,12 @@ export class OfflineQueue {
|
|
|
106
72
|
#writing = 0;
|
|
107
73
|
#written = 0;
|
|
108
74
|
|
|
109
|
-
private constructor(store: QueueStore, state: QueueState) {
|
|
75
|
+
private constructor(store: QueueStore, state: QueueState, options: OfflineQueueOptions) {
|
|
110
76
|
this.#store = store;
|
|
111
|
-
|
|
77
|
+
const max = options.maxQueued;
|
|
78
|
+
this.#maxQueued =
|
|
79
|
+
max === undefined ? undefined : finiteCount('OfflineQueue', 'maxQueued', max, 1);
|
|
80
|
+
this.#seat(state.mutations.map((mutation) => ({ ...mutation })));
|
|
112
81
|
this.#nextSeq = state.nextSeq;
|
|
113
82
|
}
|
|
114
83
|
|
|
@@ -122,8 +91,8 @@ export class OfflineQueue {
|
|
|
122
91
|
* write the old page did get through is answered from the action's idempotency store and is
|
|
123
92
|
* never applied twice. That requirement is what makes this resend safe, not the key alone.
|
|
124
93
|
*/
|
|
125
|
-
static async open(store: QueueStore): Promise<OfflineQueue> {
|
|
126
|
-
const queue = new OfflineQueue(store, await store.load());
|
|
94
|
+
static async open(store: QueueStore, options: OfflineQueueOptions = {}): Promise<OfflineQueue> {
|
|
95
|
+
const queue = new OfflineQueue(store, await store.load(), options);
|
|
127
96
|
await queue.#reclaimInflight();
|
|
128
97
|
return queue;
|
|
129
98
|
}
|
|
@@ -137,7 +106,7 @@ export class OfflineQueue {
|
|
|
137
106
|
async reload(): Promise<void> {
|
|
138
107
|
if (this.#abandoned) return;
|
|
139
108
|
const state = await this.#store.load();
|
|
140
|
-
this.#
|
|
109
|
+
this.#seat(state.mutations.map((mutation) => ({ ...mutation })));
|
|
141
110
|
this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
|
|
142
111
|
await this.#reclaimInflight();
|
|
143
112
|
}
|
|
@@ -162,7 +131,7 @@ export class OfflineQueue {
|
|
|
162
131
|
#adopt(state: QueueState): void {
|
|
163
132
|
const onDisk = new Map(state.mutations.map((mutation) => [mutation.key, mutation]));
|
|
164
133
|
const kept: QueuedMutation[] = [];
|
|
165
|
-
for (const mutation of this.#mutations) {
|
|
134
|
+
for (const mutation of this.#mutations.values()) {
|
|
166
135
|
const disk = onDisk.get(mutation.key);
|
|
167
136
|
onDisk.delete(mutation.key);
|
|
168
137
|
// Gone from the disk: another tab's pass settled it. One on THIS tab's wire stays its own.
|
|
@@ -174,18 +143,42 @@ export class OfflineQueue {
|
|
|
174
143
|
kept.push(mutation);
|
|
175
144
|
}
|
|
176
145
|
for (const disk of onDisk.values()) kept.push({ ...disk });
|
|
177
|
-
this.#
|
|
146
|
+
this.#seat(kept);
|
|
178
147
|
this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
|
|
179
148
|
}
|
|
180
149
|
|
|
150
|
+
/** The whole queue replaced — a load, a reload, a refresh: sorted ONCE here, never on a read. */
|
|
151
|
+
#seat(mutations: readonly QueuedMutation[]): void {
|
|
152
|
+
const ordered = [...mutations].sort((a, b) => a.seq - b.seq);
|
|
153
|
+
this.#mutations = new Map(ordered.map((mutation) => [mutation.key, mutation]));
|
|
154
|
+
this.#tailSeq = ordered.at(-1)?.seq ?? 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One new entry. Its seq is the queue's newest in every path today; one that is not re-seats. */
|
|
158
|
+
#append(mutation: QueuedMutation): void {
|
|
159
|
+
if (mutation.seq > this.#tailSeq) {
|
|
160
|
+
this.#mutations.set(mutation.key, mutation);
|
|
161
|
+
this.#tailSeq = mutation.seq;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.#seat([...this.#mutations.values(), mutation]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Is this the entry the queue holds for its key? Identity: a re-issued key is another entry. */
|
|
168
|
+
#holds(mutation: QueuedMutation): boolean {
|
|
169
|
+
return this.#mutations.get(mutation.key) === mutation;
|
|
170
|
+
}
|
|
171
|
+
|
|
181
172
|
async #reclaimInflight(): Promise<void> {
|
|
182
|
-
const reclaimed = this.#mutations.
|
|
173
|
+
const reclaimed = [...this.#mutations.values()].filter(
|
|
174
|
+
(mutation) => mutation.status === 'inflight',
|
|
175
|
+
);
|
|
183
176
|
for (const mutation of reclaimed) mutation.status = 'pending';
|
|
184
177
|
if (reclaimed.length > 0) await this.#persist(reclaimed);
|
|
185
178
|
}
|
|
186
179
|
|
|
187
180
|
get size(): number {
|
|
188
|
-
return this.#mutations.
|
|
181
|
+
return this.#mutations.size;
|
|
189
182
|
}
|
|
190
183
|
|
|
191
184
|
get collapsed(): number {
|
|
@@ -197,7 +190,7 @@ export class OfflineQueue {
|
|
|
197
190
|
}
|
|
198
191
|
|
|
199
192
|
find(key: string): QueuedMutation | undefined {
|
|
200
|
-
return this.#mutations.
|
|
193
|
+
return this.#mutations.get(key);
|
|
201
194
|
}
|
|
202
195
|
|
|
203
196
|
/**
|
|
@@ -205,13 +198,13 @@ export class OfflineQueue {
|
|
|
205
198
|
* anything downstream is allowed to use, and the count a UI renders as "unsynced".
|
|
206
199
|
*/
|
|
207
200
|
pending(): readonly QueuedMutation[] {
|
|
208
|
-
return this.#mutations
|
|
209
|
-
|
|
210
|
-
|
|
201
|
+
return [...this.#mutations.values()].filter(
|
|
202
|
+
(mutation) => mutation.status === 'pending' || mutation.status === 'inflight',
|
|
203
|
+
);
|
|
211
204
|
}
|
|
212
205
|
|
|
213
206
|
all(): readonly QueuedMutation[] {
|
|
214
|
-
return [...this.#mutations
|
|
207
|
+
return [...this.#mutations.values()];
|
|
215
208
|
}
|
|
216
209
|
|
|
217
210
|
async enqueue(args: {
|
|
@@ -237,12 +230,21 @@ export class OfflineQueue {
|
|
|
237
230
|
this.#collapsed += 1;
|
|
238
231
|
return existing;
|
|
239
232
|
}
|
|
233
|
+
// After the collapse, never before it: a held key — even a failed one re-issued in place — is
|
|
234
|
+
// no new write. Refused, never made room for: a dropped entry is an intent the user saw saved.
|
|
235
|
+
// Counted as `pending()` counts — still to send: a refused entry kept for the UI never drains,
|
|
236
|
+
// and counting it filled the queue for good, across reloads. Walked only once at the size.
|
|
237
|
+
const max = this.#maxQueued;
|
|
238
|
+
const full = max !== undefined && this.#mutations.size >= max && this.pending().length >= max;
|
|
239
|
+
if (existing === undefined && full) {
|
|
240
|
+
throw new OfflineQueueFullError({ name: args.name, maxQueued: max });
|
|
241
|
+
}
|
|
240
242
|
// A terminally failed entry is a decision the server already made about this key, kept for the
|
|
241
243
|
// UI — collapsing onto it makes an explicit idempotency key unusable for the rest of the
|
|
242
244
|
// session, because nothing ever retries a denial. Re-issuing one is a NEW intent, so the old
|
|
243
245
|
// entry is dropped and this one takes a new sequence at the back of the queue.
|
|
244
246
|
// By identity: `existing` IS the entry for this key, found above — no second key comparison.
|
|
245
|
-
if (existing) this.#mutations
|
|
247
|
+
if (existing) this.#mutations.delete(existing.key);
|
|
246
248
|
const mutation: QueuedMutation = {
|
|
247
249
|
key: args.key,
|
|
248
250
|
seq: this.#nextSeq,
|
|
@@ -255,7 +257,7 @@ export class OfflineQueue {
|
|
|
255
257
|
error: null,
|
|
256
258
|
};
|
|
257
259
|
this.#nextSeq += 1;
|
|
258
|
-
this.#
|
|
260
|
+
this.#append(mutation);
|
|
259
261
|
await this.#persist([mutation]);
|
|
260
262
|
return mutation;
|
|
261
263
|
}
|
|
@@ -302,7 +304,7 @@ export class OfflineQueue {
|
|
|
302
304
|
this.#epoch += 1;
|
|
303
305
|
let returned = 0;
|
|
304
306
|
const back: QueuedMutation[] = [];
|
|
305
|
-
for (const mutation of this.#mutations) {
|
|
307
|
+
for (const mutation of this.#mutations.values()) {
|
|
306
308
|
if (mutation.status !== 'inflight') continue;
|
|
307
309
|
mutation.status = 'pending';
|
|
308
310
|
returned += 1;
|
|
@@ -333,8 +335,7 @@ export class OfflineQueue {
|
|
|
333
335
|
const mutation = this.find(key);
|
|
334
336
|
if (!mutation) return;
|
|
335
337
|
mutation.status = 'acked';
|
|
336
|
-
|
|
337
|
-
this.#mutations = this.#mutations.filter((entry) => entry !== mutation);
|
|
338
|
+
this.#mutations.delete(key);
|
|
338
339
|
await this.#persist([], [key]);
|
|
339
340
|
}
|
|
340
341
|
|
|
@@ -361,14 +362,12 @@ export class OfflineQueue {
|
|
|
361
362
|
* nothing at all.
|
|
362
363
|
*/
|
|
363
364
|
#stillSendable(mutation: QueuedMutation): boolean {
|
|
364
|
-
return mutation.status === 'pending' && this.#
|
|
365
|
+
return mutation.status === 'pending' && this.#holds(mutation);
|
|
365
366
|
}
|
|
366
367
|
|
|
367
368
|
/** Never sent on this connection. `inflight` is excluded: it is already on a socket. */
|
|
368
369
|
#sendable(): readonly QueuedMutation[] {
|
|
369
|
-
return this.#mutations
|
|
370
|
-
.filter((mutation) => mutation.status === 'pending')
|
|
371
|
-
.sort((a, b) => a.seq - b.seq);
|
|
370
|
+
return [...this.#mutations.values()].filter((mutation) => mutation.status === 'pending');
|
|
372
371
|
}
|
|
373
372
|
|
|
374
373
|
/** One drain pass. Never called concurrently with itself — `drain` owns that. */
|
|
@@ -417,7 +416,7 @@ export class OfflineQueue {
|
|
|
417
416
|
} catch (error) {
|
|
418
417
|
// Settled inside `send` before it threw (the page outbox acks or fails it, then a merge may
|
|
419
418
|
// raise): writing it back as `pending` would replay what the server already decided.
|
|
420
|
-
if (this.#
|
|
419
|
+
if (this.#holds(mutation) && statusOf(mutation) !== 'failed') {
|
|
421
420
|
mutation.status = 'pending';
|
|
422
421
|
mutation.error = toQueueError(error);
|
|
423
422
|
await this.#persist([mutation]);
|
|
@@ -432,7 +431,7 @@ export class OfflineQueue {
|
|
|
432
431
|
}
|
|
433
432
|
// Only what this pass touched AND the queue still holds: an entry the server settled during the
|
|
434
433
|
// pass was already deleted by `ack`, and writing it back would resurrect it.
|
|
435
|
-
await this.#persist(touched.filter((mutation) => this.#
|
|
434
|
+
await this.#persist(touched.filter((mutation) => this.#holds(mutation)));
|
|
436
435
|
return {
|
|
437
436
|
sent,
|
|
438
437
|
collapsed: this.#collapsed,
|
|
@@ -442,8 +441,9 @@ export class OfflineQueue {
|
|
|
442
441
|
}
|
|
443
442
|
|
|
444
443
|
async clear(): Promise<void> {
|
|
445
|
-
const keys = this.#mutations.
|
|
446
|
-
this.#mutations =
|
|
444
|
+
const keys = [...this.#mutations.keys()];
|
|
445
|
+
this.#mutations = new Map();
|
|
446
|
+
this.#tailSeq = 0;
|
|
447
447
|
await this.#persist([], keys);
|
|
448
448
|
}
|
|
449
449
|
|
package/src/page-errors.ts
CHANGED
|
@@ -109,6 +109,21 @@ export class OfflineQueueAbandonedError extends RealtimeError {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* A write made while the outbox already holds its `maxQueued` unsent writes. REFUSED to the caller
|
|
114
|
+
* that made it, never made room for: dropping a queued write would lose an intent the user was
|
|
115
|
+
* shown as saved. A key already queued is no new write and is never refused.
|
|
116
|
+
*/
|
|
117
|
+
export class OfflineQueueFullError extends RealtimeError {
|
|
118
|
+
constructor(args: { name: string; maxQueued: number }) {
|
|
119
|
+
super({
|
|
120
|
+
code: 'X_OFFLINE_QUEUE_FULL',
|
|
121
|
+
cause: `the write "${args.name}" was refused: the offline queue already holds ${args.maxQueued} unsent writes (maxQueued), and none is dropped to make room — call the mutation again once the page is back online`,
|
|
122
|
+
fix: 'OfflineQueue.open(store, { maxQueued }) # raise the ceiling, or let the page reconnect so the queue drains',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
/**
|
|
113
128
|
* Something that can only mean "talk to the socket" ran on the server client — a mutation, a
|
|
114
129
|
* publish, a topic subscription, a dial. There is no socket during a server render and there never
|
package/src/page-outbox.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { ClientScope, UltimateError } from '@ultimat3/core/page';
|
|
|
8
8
|
import {
|
|
9
9
|
classifyThrown,
|
|
10
10
|
clientTransport,
|
|
11
|
+
finiteCount,
|
|
11
12
|
OUTBOX_DRAIN_MESSAGE,
|
|
12
13
|
onRescope,
|
|
13
14
|
pageClient,
|
|
@@ -74,6 +75,11 @@ export interface OutboxOptions {
|
|
|
74
75
|
readonly tabs?: ((heard: (scope: string | null) => void) => OutboxTabs) | undefined;
|
|
75
76
|
/** Where a disk that refused the queue is said, by code. Default `console.warn`. */
|
|
76
77
|
readonly warn?: ((error: UltimateError) => void) | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* The most writes queued at once, on every queue this outbox opens. Past it a NEW write is
|
|
80
|
+
* refused (`X_OFFLINE_QUEUE_FULL`) and nothing queued is dropped. Absent: no ceiling.
|
|
81
|
+
*/
|
|
82
|
+
readonly maxQueued?: number | undefined;
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
const EMPTY: DrainReport = { sent: 0, collapsed: 0, remaining: 0, stoppedAt: null };
|
|
@@ -88,6 +94,12 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
|
|
|
88
94
|
const principal =
|
|
89
95
|
options.principal ?? ((): ClientScope['principal'] => pageClient().scope.principal);
|
|
90
96
|
const send = options.send ?? sendOverHttp;
|
|
97
|
+
// Refused HERE, where the outbox is built: inside `open` a bad ceiling would read as a refused
|
|
98
|
+
// disk, and the memory fallback would throw it again with nobody to catch it.
|
|
99
|
+
const ceiling =
|
|
100
|
+
options.maxQueued === undefined
|
|
101
|
+
? {}
|
|
102
|
+
: { maxQueued: finiteCount('localOutbox', 'maxQueued', options.maxQueued, 1) };
|
|
91
103
|
const overlays =
|
|
92
104
|
options.overlays ?? ((): OutboxOverlays | undefined => peekPageRealtime()?.store);
|
|
93
105
|
/**
|
|
@@ -142,10 +154,10 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
|
|
|
142
154
|
const scope = scopeKey(principal());
|
|
143
155
|
let queue: OfflineQueue;
|
|
144
156
|
try {
|
|
145
|
-
queue = await OfflineQueue.open(queueStore(await options.local, scope));
|
|
157
|
+
queue = await OfflineQueue.open(queueStore(await options.local, scope), ceiling);
|
|
146
158
|
} catch (error) {
|
|
147
159
|
unavailable('the outbox could not be opened', error);
|
|
148
|
-
queue = await OfflineQueue.open(memoryQueueStore());
|
|
160
|
+
queue = await OfflineQueue.open(memoryQueueStore(), ceiling);
|
|
149
161
|
}
|
|
150
162
|
// The principal left while the disk was being read: this queue is the one that left with it,
|
|
151
163
|
// and the handler that saw the change has already chained the next open.
|
|
@@ -390,7 +402,9 @@ async function sendOverHttp(entry: OutboxEntry, carried: Set<string>): Promise<u
|
|
|
390
402
|
* The page's outbox, created once per tab. In a browser it replays on open, on `online`, and on the
|
|
391
403
|
* service worker's drain message; with no `document` it is a memory queue that listens for nothing.
|
|
392
404
|
*/
|
|
393
|
-
export function pageOutbox(
|
|
405
|
+
export function pageOutbox(
|
|
406
|
+
options: Pick<OutboxOptions, 'beforeEnqueue' | 'maxQueued'> = {},
|
|
407
|
+
): PageOutbox {
|
|
394
408
|
const host = globalThis as OutboxHost;
|
|
395
409
|
const existing = host[OUTBOX_KEY];
|
|
396
410
|
// Only this function writes the slot, so what it holds is always a `PageOutbox`.
|
|
@@ -399,6 +413,7 @@ export function pageOutbox(options: Pick<OutboxOptions, 'beforeEnqueue'> = {}):
|
|
|
399
413
|
const outbox = localOutbox({
|
|
400
414
|
local: pageLocalStore(),
|
|
401
415
|
beforeEnqueue: options.beforeEnqueue,
|
|
416
|
+
maxQueued: options.maxQueued,
|
|
402
417
|
// Every tab of this user shares the durable queue; each re-reads it when another changes it.
|
|
403
418
|
...(browser ? { tabs: (heard) => outboxTabs(globalThis as OutboxTabsWindow, heard) } : {}),
|
|
404
419
|
});
|
package/src/pg-connection.ts
CHANGED
|
@@ -124,7 +124,15 @@ export class PgConnection {
|
|
|
124
124
|
// or role default like `America/New_York` writes a pre-1883 instant at its LMT offset,
|
|
125
125
|
// `-04:56:02`. The decoder reads that too, but UTC text is the one form with no ambient zone
|
|
126
126
|
// in it — and a client option outranks every server-side default, `ALTER ROLE … SET` included.
|
|
127
|
-
|
|
127
|
+
//
|
|
128
|
+
// Then the SERVER's TCP keepalives. Both sessions this opens are held idle for hours — the
|
|
129
|
+
// replicator's advisory lock IS its session — and a holder lost without a FIN (a failover
|
|
130
|
+
// moving the address, a NAT drop) kept the lock until the OS default, ~2 h 11 m: no standby
|
|
131
|
+
// took over. Probed after 10 s idle, every 5 s, dead after 3 misses — ~25 s. Ignored, not
|
|
132
|
+
// refused, on a Unix socket, where there is no TCP to probe.
|
|
133
|
+
options:
|
|
134
|
+
'-c datestyle=ISO -c intervalstyle=postgres -c extra_float_digits=3 -c TimeZone=UTC ' +
|
|
135
|
+
'-c tcp_keepalives_idle=10 -c tcp_keepalives_interval=5 -c tcp_keepalives_count=3',
|
|
128
136
|
};
|
|
129
137
|
if (options.replication !== undefined) parameters['replication'] = options.replication;
|
|
130
138
|
// A handshake fails on ordinary conditions — no password, an ErrorResponse, an EOF — and on
|
package/src/pg-preflight.ts
CHANGED
|
@@ -4,10 +4,15 @@
|
|
|
4
4
|
// stop every app on the default replica identity from starting.
|
|
5
5
|
|
|
6
6
|
import { logger } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
ReplicaIdentityError,
|
|
9
|
+
ReplicationFailedError,
|
|
10
|
+
ReplicationSlotInvalidatedError,
|
|
11
|
+
} from './errors';
|
|
8
12
|
import type { PgConnection } from './pg-connection';
|
|
9
13
|
import { assertIdentifier } from './pg-identifier';
|
|
10
14
|
import { ensurePublication } from './pg-publication';
|
|
15
|
+
import { awaitSlotReleased } from './pg-slot-release';
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* The four things that are always misconfigured. `wal_level` and the slot produce an unreadable
|
|
@@ -17,13 +22,16 @@ import { ensurePublication } from './pg-publication';
|
|
|
17
22
|
* simple queries, so the identifier charset is the injection boundary; re-asserted here rather than trusted, so the guarantee
|
|
18
23
|
* travels with the function instead of living only in `start()`.
|
|
19
24
|
*/
|
|
25
|
+
/** What preflight found the slot as: there already, or created just now by this call. */
|
|
26
|
+
export type SlotState = 'existing' | 'created';
|
|
27
|
+
|
|
20
28
|
export async function preflight(
|
|
21
29
|
connection: PgConnection,
|
|
22
30
|
slot: string,
|
|
23
31
|
publication: string,
|
|
24
32
|
entities: ReadonlySet<string>,
|
|
25
33
|
fullIdentity: ReadonlySet<string> = new Set(),
|
|
26
|
-
): Promise<
|
|
34
|
+
): Promise<SlotState> {
|
|
27
35
|
assertIdentifier('slot', slot);
|
|
28
36
|
assertIdentifier('publication', publication);
|
|
29
37
|
const [walLevel] = await connection.query('SHOW wal_level');
|
|
@@ -39,14 +47,19 @@ export async function preflight(
|
|
|
39
47
|
await ensurePublication(connection, publication, entities);
|
|
40
48
|
await warnPartialIdentity(connection, entities);
|
|
41
49
|
await warnChannelIdentity(connection, entities, fullIdentity);
|
|
50
|
+
// `wal_status` in the same statement: `lost` is a slot postgres invalidated, which no restart
|
|
51
|
+
// can stream from — said with its own code, never as the generic failure START_REPLICATION gives.
|
|
52
|
+
// `active_pid` too: a walsender still on its way out holds the slot, and START_REPLICATION would
|
|
53
|
+
// refuse a restart right behind it with 55006 (`pg-slot-release.ts`).
|
|
42
54
|
const [existing] = await connection.query(
|
|
43
|
-
`SELECT plugin FROM pg_replication_slots WHERE slot_name = '${slot}'`,
|
|
55
|
+
`SELECT plugin, wal_status, active_pid FROM pg_replication_slots WHERE slot_name = '${slot}'`,
|
|
44
56
|
);
|
|
45
57
|
if (existing === undefined) {
|
|
46
58
|
// Plain SQL rather than CREATE_REPLICATION_SLOT: the replication command exports a snapshot
|
|
47
|
-
// that pins xmin for the session, and its option syntax changed in postgres 15.
|
|
59
|
+
// that pins xmin for the session, and its option syntax changed in postgres 15. The caller
|
|
60
|
+
// decides what a NEW slot means — a resume position it had is now a gap (`pg-replication-dial.ts`).
|
|
48
61
|
await connection.query(`SELECT pg_create_logical_replication_slot('${slot}', 'pgoutput')`);
|
|
49
|
-
return;
|
|
62
|
+
return 'created';
|
|
50
63
|
}
|
|
51
64
|
if (existing[0] !== 'pgoutput') {
|
|
52
65
|
throw new ReplicationFailedError({
|
|
@@ -55,6 +68,9 @@ export async function preflight(
|
|
|
55
68
|
fix: `SELECT pg_drop_replication_slot('${slot}'); -- then start the replicator again`,
|
|
56
69
|
});
|
|
57
70
|
}
|
|
71
|
+
if (existing[1] === 'lost') throw new ReplicationSlotInvalidatedError({ slot });
|
|
72
|
+
await awaitSlotReleased(connection, slot, existing[2]);
|
|
73
|
+
return 'existing';
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
/**
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Single responsibility: bring one replication stream up — open the walsender session, preflight
|
|
2
|
+
// it, and START_REPLICATION from the right place — and say whether the resume position it was
|
|
3
|
+
// handed still means anything. Reading the stream once it is up is `pg-replication.ts`'s.
|
|
4
|
+
|
|
5
|
+
import { logger } from '@ultimat3/core';
|
|
6
|
+
import type { PostgresChangeFeedOptions } from './changefeed';
|
|
7
|
+
import { ReplicationSlotRecreatedError } from './errors';
|
|
8
|
+
import { printLsn } from './pg-bytes';
|
|
9
|
+
import { PgConnection } from './pg-connection';
|
|
10
|
+
import { paramsChannelTables } from './pg-identity-tables';
|
|
11
|
+
import { preflight } from './pg-preflight';
|
|
12
|
+
import { commitPositionOf } from './pg-replication-position';
|
|
13
|
+
import { bunPgStream, parsePgUrl } from './pg-socket';
|
|
14
|
+
|
|
15
|
+
/** The identifiers the stream already put through `assertIdentifier`. */
|
|
16
|
+
export interface StreamNames {
|
|
17
|
+
readonly slot: string;
|
|
18
|
+
readonly publication: string;
|
|
19
|
+
readonly entities: ReadonlySet<string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A slot this stream created under a resume position, carried until a dial gets through. A dial
|
|
24
|
+
* that created it and then failed (START_REPLICATION refused, the socket dropped) leaves the next
|
|
25
|
+
* one finding it EXISTING — and the gap no smaller. Owned by the stream, written here.
|
|
26
|
+
*/
|
|
27
|
+
export interface SlotGap {
|
|
28
|
+
createdUnder: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DialedStream {
|
|
32
|
+
readonly connection: PgConnection;
|
|
33
|
+
/** Where START_REPLICATION began — what the stream confirms until its first commit. */
|
|
34
|
+
readonly confirmed: bigint;
|
|
35
|
+
/** The resume filter for this run: `undefined` once the slot it pointed into is gone. */
|
|
36
|
+
readonly from: string | undefined;
|
|
37
|
+
/** Why every reader must re-read now, or `null` when the stream is continuous. */
|
|
38
|
+
readonly resync: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Open, preflight, START_REPLICATION. On any failure after the socket opened, the session is closed
|
|
43
|
+
* before the failure is rethrown: the dial failure explains the boot, a close that also failed
|
|
44
|
+
* does not, and nothing is left holding the slot.
|
|
45
|
+
*/
|
|
46
|
+
export async function dialReplication(
|
|
47
|
+
options: PostgresChangeFeedOptions,
|
|
48
|
+
names: StreamNames,
|
|
49
|
+
from: string | undefined,
|
|
50
|
+
gap: SlotGap,
|
|
51
|
+
): Promise<DialedStream> {
|
|
52
|
+
const target = parsePgUrl(options.url);
|
|
53
|
+
const stream = await (options.stream ?? bunPgStream)(target);
|
|
54
|
+
const connection = await PgConnection.open({
|
|
55
|
+
stream,
|
|
56
|
+
user: target.user,
|
|
57
|
+
password: target.password,
|
|
58
|
+
database: target.database,
|
|
59
|
+
replication: 'database',
|
|
60
|
+
applicationName: `ultimate-replicator:${names.slot}`,
|
|
61
|
+
rng: options.rng,
|
|
62
|
+
ssl: target.ssl,
|
|
63
|
+
});
|
|
64
|
+
try {
|
|
65
|
+
const full = new Set(options.fullIdentityTables ?? paramsChannelTables());
|
|
66
|
+
const slot = await preflight(connection, names.slot, names.publication, names.entities, full);
|
|
67
|
+
// A NEW slot under a resume position is a gap: it starts at the server's current WAL, so the
|
|
68
|
+
// changes between the old confirm and now were never streamed — and after a failover the old
|
|
69
|
+
// position is another server's WAL, so it must neither start the stream nor filter it.
|
|
70
|
+
if (slot === 'created' && from !== undefined) gap.createdUnder ??= from;
|
|
71
|
+
const under = from === undefined ? null : gap.createdUnder;
|
|
72
|
+
const recreated = under !== null;
|
|
73
|
+
const resumeFrom = recreated ? undefined : from;
|
|
74
|
+
if (recreated) {
|
|
75
|
+
const said = new ReplicationSlotRecreatedError({ slot: names.slot, from: under });
|
|
76
|
+
// FIELDS, never interpolation; the message is the CODE alone (`pg-preflight.ts`).
|
|
77
|
+
logger.warn(said.code, { cause: said.cause, fix: said.fix, slot: names.slot, from: under });
|
|
78
|
+
}
|
|
79
|
+
const confirmed = resumeFrom === undefined ? 0n : commitPositionOf(resumeFrom);
|
|
80
|
+
await connection.startCopyBoth(
|
|
81
|
+
`START_REPLICATION SLOT ${names.slot} LOGICAL ${printLsn(confirmed)} ` +
|
|
82
|
+
`(proto_version '1', publication_names '${names.publication}', messages 'true')`,
|
|
83
|
+
);
|
|
84
|
+
// Through: the gap is the caller's to say now, and no later dial owes it again.
|
|
85
|
+
gap.createdUnder = null;
|
|
86
|
+
const resync = recreated
|
|
87
|
+
? `replication slot "${names.slot}" was recreated under resume position ${under}: ` +
|
|
88
|
+
'the changes in between were never streamed'
|
|
89
|
+
: null;
|
|
90
|
+
return { connection, confirmed, from: resumeFrom, resync };
|
|
91
|
+
} catch (failure) {
|
|
92
|
+
await connection.close().catch(() => undefined);
|
|
93
|
+
throw failure;
|
|
94
|
+
}
|
|
95
|
+
}
|