@ultimat3/realtime 22.15.0 → 24.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 +89 -102
- package/README.md +140 -20
- package/package.json +4 -4
- package/src/advisory-lock.ts +57 -0
- package/src/boot.ts +12 -7
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +2 -2
- package/src/changefeed.ts +39 -1
- package/src/channel-authz.ts +43 -12
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +34 -4
- package/src/channel-records.ts +33 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +143 -44
- package/src/client-channels.ts +10 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +34 -4
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +1 -0
- package/src/cursor.ts +11 -0
- package/src/errors.ts +70 -19
- package/src/fanout.ts +13 -0
- package/src/index.ts +1 -1
- package/src/live-contract.ts +6 -2
- package/src/live-definition.ts +46 -11
- package/src/live-fanout.ts +70 -11
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +38 -0
- package/src/live-query.ts +119 -91
- package/src/live-refusal.ts +21 -0
- package/src/live-replicator.ts +6 -6
- package/src/live-resume.ts +74 -0
- package/src/live-tenant.ts +25 -0
- package/src/nats-client.ts +11 -2
- package/src/nats-open.ts +9 -0
- package/src/nats-transport.ts +23 -1
- package/src/offline-queue.ts +48 -5
- package/src/page-errors.ts +17 -0
- package/src/page-outbox.ts +121 -50
- package/src/pg-advisory-lock.ts +45 -1
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +119 -21
- package/src/pg-entity-row.ts +27 -2
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +44 -45
- package/src/pg-socket.ts +6 -4
- package/src/pg-tls.ts +18 -0
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/presence.ts +85 -8
- package/src/query-window.ts +28 -7
- package/src/record-persister.ts +7 -2
- package/src/replication-errors.ts +8 -4
- package/src/replicator-envelope.ts +110 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +319 -186
- package/src/server.ts +11 -7
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/subscriber-gate.ts +11 -1
- package/src/sync-auth.ts +47 -2
- package/src/sync-frames.ts +73 -20
- package/src/sync-node-bounds.ts +15 -0
- package/src/sync-node-contract.ts +20 -2
- package/src/sync-node.ts +92 -17
- package/src/sync-origin.ts +26 -11
- package/src/sync-protocol.ts +13 -3
- package/src/sync-upgrade.ts +76 -8
- package/src/use-mutation.ts +33 -5
- package/src/wire-channel.ts +13 -4
package/src/offline-queue.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { renderThrowable, stringField } from '@ultimat3/core/page';
|
|
13
13
|
import type { JsonValue } from './json';
|
|
14
|
+
import { OfflineQueueAbandonedError } from './page-errors';
|
|
14
15
|
import type { WireError } from './sync-protocol';
|
|
15
16
|
|
|
16
17
|
export type MutationStatus = 'pending' | 'inflight' | 'acked' | 'failed';
|
|
@@ -91,6 +92,8 @@ export class OfflineQueue {
|
|
|
91
92
|
* is parked inside `send`. Bumped by every loss so a pass that resumes afterwards claims nothing.
|
|
92
93
|
*/
|
|
93
94
|
#epoch = 0;
|
|
95
|
+
/** Set by `abandon`, never cleared: this queue's principal is gone. */
|
|
96
|
+
#abandoned = false;
|
|
94
97
|
|
|
95
98
|
private constructor(store: QueueStore, state: QueueState) {
|
|
96
99
|
this.#store = store;
|
|
@@ -103,8 +106,10 @@ export class OfflineQueue {
|
|
|
103
106
|
*
|
|
104
107
|
* An `inflight` entry on disk belonged to a page that is gone, so it goes back to `pending`. Left
|
|
105
108
|
* as it was, `#sendable` skipped it forever — no ack was coming to a page that no longer exists —
|
|
106
|
-
* and every later write overtook it. The replay carries its idempotency key,
|
|
107
|
-
*
|
|
109
|
+
* and every later write overtook it. The replay carries its idempotency key, and a `mutator()`
|
|
110
|
+
* is REQUIRED to declare `idempotent: true` (`X_MUTATOR_NOT_IDEMPOTENT` at declaration), so a
|
|
111
|
+
* write the old page did get through is answered from the action's idempotency store and is
|
|
112
|
+
* never applied twice. That requirement is what makes this resend safe, not the key alone.
|
|
108
113
|
*/
|
|
109
114
|
static async open(store: QueueStore): Promise<OfflineQueue> {
|
|
110
115
|
const queue = new OfflineQueue(store, await store.load());
|
|
@@ -119,6 +124,7 @@ export class OfflineQueue {
|
|
|
119
124
|
* other pass can have one on the wire.
|
|
120
125
|
*/
|
|
121
126
|
async reload(): Promise<void> {
|
|
127
|
+
if (this.#abandoned) return;
|
|
122
128
|
const state = await this.#store.load();
|
|
123
129
|
this.#mutations = state.mutations.map((mutation) => ({ ...mutation }));
|
|
124
130
|
this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
|
|
@@ -167,6 +173,17 @@ export class OfflineQueue {
|
|
|
167
173
|
input: JsonValue;
|
|
168
174
|
at?: number;
|
|
169
175
|
}): Promise<QueuedMutation> {
|
|
176
|
+
// Read BEFORE the key is looked up, never between the lookup and the push: with the await in
|
|
177
|
+
// between, two enqueues of one key both found nothing and both pushed — one intent, two
|
|
178
|
+
// entries under one sequence number. Another tab of the same user may have taken sequence
|
|
179
|
+
// numbers since this one loaded, which is what the read is for.
|
|
180
|
+
//
|
|
181
|
+
// An abandoned queue REFUSES, before the read and after it (the principal can leave during
|
|
182
|
+
// it): a write accepted here would sit in memory, shown as queued, with nothing to send it.
|
|
183
|
+
if (this.#abandoned) throw new OfflineQueueAbandonedError({ name: args.name });
|
|
184
|
+
const floor = (await this.#store.load()).nextSeq;
|
|
185
|
+
if (this.#abandoned) throw new OfflineQueueAbandonedError({ name: args.name });
|
|
186
|
+
this.#nextSeq = Math.max(this.#nextSeq, floor);
|
|
170
187
|
const existing = this.find(args.key);
|
|
171
188
|
if (existing && existing.status !== 'failed') {
|
|
172
189
|
this.#collapsed += 1;
|
|
@@ -178,8 +195,6 @@ export class OfflineQueue {
|
|
|
178
195
|
// entry is dropped and this one takes a new sequence at the back of the queue.
|
|
179
196
|
// By identity: `existing` IS the entry for this key, found above — no second key comparison.
|
|
180
197
|
if (existing) this.#mutations = this.#mutations.filter((entry) => entry !== existing);
|
|
181
|
-
// Another tab of the same user may have taken sequence numbers since this one loaded.
|
|
182
|
-
this.#nextSeq = Math.max(this.#nextSeq, (await this.#store.load()).nextSeq);
|
|
183
198
|
const mutation: QueuedMutation = {
|
|
184
199
|
key: args.key,
|
|
185
200
|
seq: this.#nextSeq,
|
|
@@ -248,6 +263,22 @@ export class OfflineQueue {
|
|
|
248
263
|
return returned;
|
|
249
264
|
}
|
|
250
265
|
|
|
266
|
+
/**
|
|
267
|
+
* The principal this queue belongs to is gone (`page-outbox.ts` calls it on a `rescope`), and it
|
|
268
|
+
* is final: nothing more is SENT, WRITTEN or ACCEPTED (`enqueue` refuses). The epoch bump stops a pass parked
|
|
269
|
+
* inside `send` before its next entry — which would otherwise leave under the next principal's
|
|
270
|
+
* session — and the flag stops a pass that starts later, and every write-back: the store was
|
|
271
|
+
* wiped for this principal, and a pass that then saved what it touched put the writes back.
|
|
272
|
+
*/
|
|
273
|
+
abandon(): void {
|
|
274
|
+
this.#epoch += 1;
|
|
275
|
+
this.#abandoned = true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
get abandoned(): boolean {
|
|
279
|
+
return this.#abandoned;
|
|
280
|
+
}
|
|
281
|
+
|
|
251
282
|
/** Server acknowledged: the mutation leaves the queue and its rebase entry can be committed. */
|
|
252
283
|
async ack(key: string): Promise<void> {
|
|
253
284
|
const mutation = this.find(key);
|
|
@@ -294,7 +325,7 @@ export class OfflineQueue {
|
|
|
294
325
|
/** One drain pass. Never called concurrently with itself — `drain` owns that. */
|
|
295
326
|
async #pass(send: MutationSender): Promise<DrainReport> {
|
|
296
327
|
const epoch = this.#epoch;
|
|
297
|
-
const sendable = this.#sendable();
|
|
328
|
+
const sendable = this.#abandoned ? [] : this.#sendable();
|
|
298
329
|
// Nothing to do: a pass chained behind one that already sent everything must not rewrite the
|
|
299
330
|
// durable state for the privilege of reporting zero.
|
|
300
331
|
if (sendable.length === 0) {
|
|
@@ -326,6 +357,12 @@ export class OfflineQueue {
|
|
|
326
357
|
// `WebSocket.send` on a CLOSING socket discards it and returns normally — so calling that
|
|
327
358
|
// an ack drops the mutation on exactly the socket death this queue exists to survive.
|
|
328
359
|
// Only `ack`/`fail` (the server) or `requeueInflight` (a lost connection) moves it on.
|
|
360
|
+
//
|
|
361
|
+
// A sender may settle the write itself before it returns — the page outbox does, over
|
|
362
|
+
// HTTP. One it REFUSED keeps the error `fail` just recorded and is not counted as sent:
|
|
363
|
+
// clearing it here put `{ status: 'failed', error: null }` on disk and reported a refused
|
|
364
|
+
// write as a sent one.
|
|
365
|
+
if (statusOf(mutation) === 'failed') continue;
|
|
329
366
|
mutation.error = null;
|
|
330
367
|
sent += 1;
|
|
331
368
|
} catch (error) {
|
|
@@ -364,6 +401,7 @@ export class OfflineQueue {
|
|
|
364
401
|
* queue: a second tab's entries are not this tab's to overwrite or delete.
|
|
365
402
|
*/
|
|
366
403
|
async #persist(puts: readonly QueuedMutation[], deletes: readonly string[] = []): Promise<void> {
|
|
404
|
+
if (this.#abandoned) return;
|
|
367
405
|
await this.#store.write({
|
|
368
406
|
puts: puts.map((mutation) => ({ ...mutation })),
|
|
369
407
|
deletes,
|
|
@@ -372,6 +410,11 @@ export class OfflineQueue {
|
|
|
372
410
|
}
|
|
373
411
|
}
|
|
374
412
|
|
|
413
|
+
/** Read through a call: `send` awaited in between, and the compiler still narrows to `inflight`. */
|
|
414
|
+
function statusOf(mutation: QueuedMutation): MutationStatus {
|
|
415
|
+
return mutation.status;
|
|
416
|
+
}
|
|
417
|
+
|
|
375
418
|
/** A thrown value as the queue records it — `code`, `cause`, `fix`, never a throw of its own. */
|
|
376
419
|
export function toQueueError(error: unknown): WireError {
|
|
377
420
|
return {
|
package/src/page-errors.ts
CHANGED
|
@@ -93,6 +93,23 @@ export class LocalStoreUnavailableError extends RealtimeError {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* A write reached a queue whose principal is gone: the page signed out, or in as someone else,
|
|
98
|
+
* while the write was being queued. It is not stored and never sent — a write issued under one
|
|
99
|
+
* session must not leave under the next — and it is RAISED, because a user's write dropped in
|
|
100
|
+
* silence is a loss nobody is told about. `name` is the mutator's registered name; the write's
|
|
101
|
+
* idempotency key is deliberately not rendered.
|
|
102
|
+
*/
|
|
103
|
+
export class OfflineQueueAbandonedError extends RealtimeError {
|
|
104
|
+
constructor(args: { name: string }) {
|
|
105
|
+
super({
|
|
106
|
+
code: 'X_OFFLINE_QUEUE_ABANDONED',
|
|
107
|
+
cause: `the write "${args.name}" was queued after its page changed principal, so it was not stored and will not be sent — call the mutation again under the current session`,
|
|
108
|
+
fix: 'x errors explain X_OFFLINE_QUEUE_ABANDONED --json',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
96
113
|
/**
|
|
97
114
|
* Something that can only mean "talk to the socket" ran on the server client — a mutation, a
|
|
98
115
|
* publish, a topic subscription, a dial. There is no socket during a server render and there never
|
package/src/page-outbox.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The page's ONE outbox: writes a page could not send
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* replay after a lost response is answered from the action's idempotency store and never applied
|
|
6
|
-
* twice. Replayed on open, on `online`, and on the service worker's `OUTBOX_DRAIN_MESSAGE`.
|
|
7
|
-
* A principal change wipes the previous principal's queue: its writes are never sent as the next.
|
|
2
|
+
* The page's ONE outbox: writes a page could not send, persisted under the current principal and
|
|
3
|
+
* replayed IN ORDER over HTTP, each under its own idempotency key. AT LEAST once — an entry leaves
|
|
4
|
+
* the disk when its ack is written. A principal change abandons the pass and wipes the queue.
|
|
8
5
|
*/
|
|
9
6
|
|
|
10
|
-
import type { ClientScope } from '@ultimat3/core/page';
|
|
7
|
+
import type { ClientScope, UltimateError } from '@ultimat3/core/page';
|
|
11
8
|
import {
|
|
12
9
|
actionPath,
|
|
13
10
|
classifyThrown,
|
|
@@ -15,12 +12,14 @@ import {
|
|
|
15
12
|
OUTBOX_DRAIN_MESSAGE,
|
|
16
13
|
onRescope,
|
|
17
14
|
pageClient,
|
|
15
|
+
renderThrowable,
|
|
18
16
|
} from '@ultimat3/core/page';
|
|
19
17
|
import type { LocalStore } from './local-store-idb';
|
|
20
18
|
import { pageLocalStore, scopeKey } from './local-store-idb';
|
|
21
19
|
import type { DrainReport, QueuedMutation, QueueState, QueueStore } from './offline-queue';
|
|
22
20
|
import { MemoryQueueStore, OfflineQueue, toQueueError } from './offline-queue';
|
|
23
21
|
import { OUTBOX_KEY, type OutboxEntry, type OutboxHandle, type OutboxHost } from './outbox-slot';
|
|
22
|
+
import { LocalStoreUnavailableError, OfflineQueueAbandonedError } from './page-errors';
|
|
24
23
|
import { peekPageRealtime } from './page-store';
|
|
25
24
|
import { carriedBy } from './record-store';
|
|
26
25
|
|
|
@@ -63,70 +62,142 @@ export interface OutboxOptions {
|
|
|
63
62
|
* the write queued and the post it liked missing, and showed the old count.
|
|
64
63
|
*/
|
|
65
64
|
readonly beforeEnqueue?: (() => Promise<void>) | undefined;
|
|
65
|
+
/** Where a disk that refused the queue is said, by code. Default `console.warn`. */
|
|
66
|
+
readonly warn?: ((error: UltimateError) => void) | undefined;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
const EMPTY: DrainReport = { sent: 0, collapsed: 0, remaining: 0, stoppedAt: null };
|
|
69
70
|
|
|
71
|
+
/** One principal's open queue, and the scope the drain lock is named after. */
|
|
72
|
+
interface Held {
|
|
73
|
+
readonly queue: OfflineQueue;
|
|
74
|
+
readonly scope: string | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
70
77
|
export function createOutbox(options: OutboxOptions): PageOutbox {
|
|
71
78
|
const principal =
|
|
72
79
|
options.principal ?? ((): ClientScope['principal'] => pageClient().scope.principal);
|
|
73
80
|
const send = options.send ?? sendOverHttp;
|
|
74
81
|
const overlays =
|
|
75
82
|
options.overlays ?? ((): OutboxOverlays | undefined => peekPageRealtime()?.store);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The open queue AND the scope it belongs to, as one value: a pass captures both when it starts,
|
|
85
|
+
* so nothing it does later can be read off a principal that arrived since.
|
|
86
|
+
*/
|
|
87
|
+
let held: Held | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Bumped by every principal change, SYNCHRONOUSLY. Anything here that resolves a queue after an
|
|
90
|
+
* await compares it first: the answer to "whose queue" must be the one from before the await.
|
|
91
|
+
*/
|
|
92
|
+
let epoch = 0;
|
|
93
|
+
const warn = options.warn ?? ((error: UltimateError): void => console.warn(error));
|
|
94
|
+
const unavailable = (what: string, error: unknown): void =>
|
|
95
|
+
warn(new LocalStoreUnavailableError({ reason: `${what}: ${renderThrowable(error)}` }));
|
|
79
96
|
|
|
97
|
+
/** Never rejects: a queue the disk would not open is a memory queue, said once by code. */
|
|
80
98
|
const open = async (): Promise<void> => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
let ready = open();
|
|
85
|
-
let running: Promise<DrainReport> | undefined;
|
|
86
|
-
/** A trigger landed while a pass ran: one more pass follows it, never one per trigger. */
|
|
87
|
-
let again = false;
|
|
88
|
-
|
|
89
|
-
const deliver = async (mutation: QueuedMutation): Promise<void> => {
|
|
90
|
-
const current = queue;
|
|
91
|
-
const carried = new Set<string>();
|
|
99
|
+
const at = epoch;
|
|
100
|
+
const scope = scopeKey(principal());
|
|
101
|
+
let queue: OfflineQueue;
|
|
92
102
|
try {
|
|
93
|
-
|
|
103
|
+
queue = await OfflineQueue.open(queueStore(await options.local, scope));
|
|
94
104
|
} catch (error) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
unavailable('the outbox could not be opened', error);
|
|
106
|
+
queue = await OfflineQueue.open(new MemoryQueueStore());
|
|
107
|
+
}
|
|
108
|
+
// The principal left while the disk was being read: this queue is the one that left with it,
|
|
109
|
+
// and the handler that saw the change has already chained the next open.
|
|
110
|
+
if (at !== epoch) {
|
|
111
|
+
queue.abandon();
|
|
102
112
|
return;
|
|
103
113
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
held?.queue.abandon();
|
|
115
|
+
held = { queue, scope };
|
|
116
|
+
};
|
|
117
|
+
let ready = open();
|
|
118
|
+
/** The current principal's queue: `ready` is re-made by a rescope, so it is re-read until still. */
|
|
119
|
+
const current = async (): Promise<Held | undefined> => {
|
|
120
|
+
for (;;) {
|
|
121
|
+
const opening = ready;
|
|
122
|
+
await opening;
|
|
123
|
+
if (opening === ready) return held;
|
|
113
124
|
}
|
|
114
125
|
};
|
|
126
|
+
let running: Promise<DrainReport> | undefined;
|
|
127
|
+
/** A trigger landed while a pass ran: one more pass follows it, never one per trigger. */
|
|
128
|
+
let again = false;
|
|
129
|
+
|
|
130
|
+
const deliver =
|
|
131
|
+
(queue: OfflineQueue) =>
|
|
132
|
+
async (mutation: QueuedMutation): Promise<void> => {
|
|
133
|
+
const carried = new Set<string>();
|
|
134
|
+
try {
|
|
135
|
+
await send({ key: mutation.key, name: mutation.name, input: mutation.input }, carried);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const kind = classifyThrown(error);
|
|
138
|
+
// The network, or a server asking to be asked again: stays queued, and the pass stops so
|
|
139
|
+
// nothing behind it overtakes it. So does a queue whose principal left mid-send.
|
|
140
|
+
if (kind === 'retryable' || kind === 'retry-after' || queue.abandoned) throw error;
|
|
141
|
+
// Anything else is the server's decision about this write — kept for the UI, never resent.
|
|
142
|
+
await queue.fail(mutation.key, toQueueError(error));
|
|
143
|
+
overlays()?.drop(mutation.key);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// The principal changed while this was on the wire: its store and its twins are already
|
|
147
|
+
// gone, and what is on the page now is somebody else's.
|
|
148
|
+
if (queue.abandoned) return;
|
|
149
|
+
await queue.ack(mutation.key);
|
|
150
|
+
const store = overlays();
|
|
151
|
+
try {
|
|
152
|
+
// Exactly as a live write settles: a row the answer did not carry keeps its overlay until
|
|
153
|
+
// the server's row for it arrives.
|
|
154
|
+
store?.settle(mutation.key, carried);
|
|
155
|
+
} catch {
|
|
156
|
+
// A custom merge that answered no row: the server's truth stands.
|
|
157
|
+
store?.drop(mutation.key);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
115
160
|
|
|
116
161
|
onRescope((_next, prev) => {
|
|
117
162
|
const gone = scopeKey(prev.principal);
|
|
163
|
+
// SYNCHRONOUSLY, before anything is awaited: a pass parked inside `send` resumes into the next
|
|
164
|
+
// principal's session, and the wipe below reaches the disk but never the pass. Abandoned, it
|
|
165
|
+
// sends nothing more and writes nothing back.
|
|
166
|
+
held?.queue.abandon();
|
|
167
|
+
held = undefined;
|
|
168
|
+
epoch += 1;
|
|
169
|
+
// The single-flight slot is the principal's too: left set, the next principal's trigger JOINED
|
|
170
|
+
// the abandoned pass — handed its report, another principal's key in it — and its own queue
|
|
171
|
+
// was not drained until some later trigger. `settled` only clears a slot that is still its own.
|
|
172
|
+
running = undefined;
|
|
173
|
+
again = false;
|
|
118
174
|
ready = ready.then(async () => {
|
|
119
|
-
|
|
175
|
+
try {
|
|
176
|
+
if (gone !== undefined) await (await options.local).wipe(gone);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// Still on disk, under the scope that left: unreachable from this principal, and the next
|
|
179
|
+
// boot's `wipeOthers` takes it. Said, never thrown — a rejection here used to poison the
|
|
180
|
+
// chain, so every later enqueue, replay and rescope on this tab failed with it.
|
|
181
|
+
unavailable("the previous principal's outbox could not be wiped", error);
|
|
182
|
+
}
|
|
120
183
|
await open();
|
|
121
184
|
});
|
|
122
185
|
});
|
|
123
186
|
|
|
124
187
|
const self: PageOutbox = {
|
|
125
188
|
enqueue: async (entry) => {
|
|
189
|
+
// WHOSE queue is decided now, before anything is awaited: resolved after the flush below,
|
|
190
|
+
// a write issued under one principal and parked there was queued — and sent — as the next.
|
|
191
|
+
// A queue still opening is waited for, and belongs to this call only if nobody left since.
|
|
192
|
+
const at = epoch;
|
|
193
|
+
const mine = held ?? (await current());
|
|
194
|
+
if (mine === undefined || at !== epoch) {
|
|
195
|
+
throw new OfflineQueueAbandonedError({ name: entry.name });
|
|
196
|
+
}
|
|
126
197
|
// A disk that refused the rows must not also cost the write: the intent still goes on disk.
|
|
127
198
|
await options.beforeEnqueue?.().catch(() => undefined);
|
|
128
|
-
|
|
129
|
-
await queue
|
|
199
|
+
// Abandoned meanwhile, it refuses by the same code.
|
|
200
|
+
await mine.queue.enqueue(entry);
|
|
130
201
|
},
|
|
131
202
|
replay: () => {
|
|
132
203
|
// Single flight: a trigger that lands while a replay is running JOINS it. Open, `online`,
|
|
@@ -140,20 +211,19 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
|
|
|
140
211
|
return running;
|
|
141
212
|
}
|
|
142
213
|
const pass = (async (): Promise<DrainReport> => {
|
|
143
|
-
await
|
|
144
|
-
if (
|
|
214
|
+
const now = await current();
|
|
215
|
+
if (now === undefined) return EMPTY;
|
|
145
216
|
// Checked when the pass STARTS, whoever asked (the socket's reconnect asks too): an
|
|
146
217
|
// attempt the browser already knows cannot leave is a failed request on the wire and
|
|
147
218
|
// nothing more. `online` asks again.
|
|
148
|
-
if (knownOffline()) return { ...EMPTY, remaining: queue.pending().length };
|
|
149
|
-
const draining = queue;
|
|
219
|
+
if (knownOffline()) return { ...EMPTY, remaining: now.queue.pending().length };
|
|
150
220
|
// One tab drains a principal's outbox at a time, and it drains what EVERY tab queued: the
|
|
151
221
|
// queue is re-read under the lock, so a write another tab made since this one opened is
|
|
152
222
|
// sent too, and an `inflight` entry — which only a pass holding this lock could have put
|
|
153
223
|
// on the wire, and it is over — goes back to `pending`.
|
|
154
|
-
return await exclusive(`ultimate-outbox:${scope ?? 'memory'}`, async () => {
|
|
155
|
-
await
|
|
156
|
-
return await
|
|
224
|
+
return await exclusive(`ultimate-outbox:${now.scope ?? 'memory'}`, async () => {
|
|
225
|
+
await now.queue.reload();
|
|
226
|
+
return await now.queue.drain(deliver(now.queue));
|
|
157
227
|
});
|
|
158
228
|
})();
|
|
159
229
|
const settled = (report?: DrainReport): void => {
|
|
@@ -172,9 +242,10 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
|
|
|
172
242
|
return pass;
|
|
173
243
|
},
|
|
174
244
|
get size(): number {
|
|
175
|
-
return queue
|
|
245
|
+
return held?.queue.pending().length ?? 0;
|
|
176
246
|
},
|
|
177
|
-
pending: () =>
|
|
247
|
+
pending: () =>
|
|
248
|
+
(held?.queue.pending() ?? []).map(({ key, name, input }) => ({ key, name, input })),
|
|
178
249
|
get ready(): Promise<void> {
|
|
179
250
|
return ready;
|
|
180
251
|
},
|
package/src/pg-advisory-lock.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// is scoped to this connection's Postgres *session*, so acquiring means keeping the connection
|
|
4
4
|
// open and releasing means closing it: no lease, no renewal, no fencing token, ever.
|
|
5
5
|
|
|
6
|
+
import type { AdvisoryLock } from './advisory-lock';
|
|
6
7
|
import { ReplicationFailedError } from './errors';
|
|
7
8
|
import { PgConnection, type PgRows } from './pg-connection';
|
|
8
9
|
import { bunPgStream, type PgTarget, parsePgUrl } from './pg-socket';
|
|
9
10
|
import type { PgStream } from './pg-wire';
|
|
10
|
-
import type { AdvisoryLock } from './replicator';
|
|
11
11
|
import type { Rng } from './thundering-herd';
|
|
12
12
|
|
|
13
13
|
/** A key reaches a simple query unparameterised, so its charset is the injection boundary. */
|
|
@@ -45,6 +45,7 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
45
45
|
* the process dies. A standby then never takes over a slot whose owner has already stopped.
|
|
46
46
|
*/
|
|
47
47
|
#acquiring: Promise<boolean> | null = null;
|
|
48
|
+
readonly #lostListeners = new Set<(reason: string) => void>();
|
|
48
49
|
|
|
49
50
|
constructor(options: PgAdvisoryLockOptions) {
|
|
50
51
|
if (!KEY_PATTERN.test(options.key)) {
|
|
@@ -91,6 +92,7 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
91
92
|
database: target.database,
|
|
92
93
|
applicationName: `ultimate-replicator-lock:${this.key}`,
|
|
93
94
|
rng: this.#options.rng,
|
|
95
|
+
ssl: target.ssl,
|
|
94
96
|
});
|
|
95
97
|
let rows: PgRows;
|
|
96
98
|
try {
|
|
@@ -110,9 +112,51 @@ export class PgAdvisoryLock implements AdvisoryLock {
|
|
|
110
112
|
// `release()` — which is exactly what lets a crashed replicator free the slot with nothing
|
|
111
113
|
// left to clean up.
|
|
112
114
|
this.#connection = connection;
|
|
115
|
+
// The session is idle from here on, and an idle session has no reader: without this a server
|
|
116
|
+
// that ended it was never noticed, and `tryAcquire()` went on answering `true` from memory
|
|
117
|
+
// for a lock Postgres had already handed to another process.
|
|
118
|
+
connection.watchIdle((reason) => this.#lost(connection, reason));
|
|
113
119
|
return true;
|
|
114
120
|
}
|
|
115
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Drop the session without the unlock statement. The socket is closed outright — no goodbye is
|
|
124
|
+
* written, because a write to a black-holed socket is one more thing that never settles — and
|
|
125
|
+
* Postgres releases the lock when the session ends. An acquisition still in flight is dropped
|
|
126
|
+
* the moment it lands.
|
|
127
|
+
*/
|
|
128
|
+
abandon(): void {
|
|
129
|
+
const acquiring = this.#acquiring;
|
|
130
|
+
if (acquiring !== null) {
|
|
131
|
+
// Voided: its own caller has the outcome; this only makes sure nothing stays held after it.
|
|
132
|
+
void acquiring.then(
|
|
133
|
+
() => this.abandon(),
|
|
134
|
+
() => undefined,
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const connection = this.#connection;
|
|
139
|
+
if (connection === null) return;
|
|
140
|
+
this.#connection = null;
|
|
141
|
+
connection.destroy();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onLost(listener: (reason: string) => void): () => void {
|
|
145
|
+
this.#lostListeners.add(listener);
|
|
146
|
+
return () => {
|
|
147
|
+
this.#lostListeners.delete(listener);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The session ended underneath the lock. A release that got there first owns the goodbye. */
|
|
152
|
+
#lost(connection: PgConnection, reason: string): void {
|
|
153
|
+
if (this.#connection !== connection) return;
|
|
154
|
+
this.#connection = null;
|
|
155
|
+
// The socket is already gone: nothing is said to it, and the listeners hear first.
|
|
156
|
+
connection.destroy();
|
|
157
|
+
for (const listener of [...this.#lostListeners]) listener(reason);
|
|
158
|
+
}
|
|
159
|
+
|
|
116
160
|
async release(): Promise<void> {
|
|
117
161
|
// A release that arrives mid-acquisition waits it out. `#connection` is still `null` there, so
|
|
118
162
|
// the early return below would answer "nothing to release" and the session would open behind
|
package/src/pg-auth.ts
CHANGED
|
@@ -63,8 +63,10 @@ class ScramSha256Session implements ScramSession {
|
|
|
63
63
|
if (!serverNonce.startsWith(this.#clientNonce)) {
|
|
64
64
|
throw new ReplicationProtocolError({
|
|
65
65
|
stage: 'auth',
|
|
66
|
-
detail:
|
|
67
|
-
|
|
66
|
+
detail:
|
|
67
|
+
`server nonce "${serverNonce}" does not extend client nonce "${this.#clientNonce}" — ` +
|
|
68
|
+
'the server did not echo the client nonce; check for a proxy on the replication URL',
|
|
69
|
+
fix: 'x doctor --json',
|
|
68
70
|
});
|
|
69
71
|
}
|
|
70
72
|
|
|
@@ -216,8 +218,11 @@ function parseServerFirst(text: string): ServerFirst {
|
|
|
216
218
|
if (iterations > MAX_SCRAM_ITERATIONS) {
|
|
217
219
|
throw new ReplicationProtocolError({
|
|
218
220
|
stage: 'auth',
|
|
219
|
-
detail:
|
|
220
|
-
|
|
221
|
+
detail:
|
|
222
|
+
`server-first-message asked for ${iterationsText} PBKDF2 iterations, above the ` +
|
|
223
|
+
`${MAX_SCRAM_ITERATIONS} ceiling — a real server asks for 4096, so point the replication ` +
|
|
224
|
+
'URL at postgres itself',
|
|
225
|
+
fix: 'x doctor --json',
|
|
221
226
|
});
|
|
222
227
|
}
|
|
223
228
|
const salt = decodeBase64('salt', saltB64);
|
package/src/pg-bytes.ts
CHANGED
|
@@ -129,7 +129,10 @@ export class ByteWriter {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
uint8(value: number): this {
|
|
132
|
-
this.#bytes[this.#room(1)]
|
|
132
|
+
// The offset FIRST: `#room` may replace `#bytes`, and `this.#bytes[this.#room(1)]` reads the
|
|
133
|
+
// old buffer before it does — the byte that caused a growth was written into the discard.
|
|
134
|
+
const at = this.#room(1);
|
|
135
|
+
this.#bytes[at] = value & 0xff;
|
|
133
136
|
return this;
|
|
134
137
|
}
|
|
135
138
|
|