@ultimat3/realtime 1.2.0 → 2.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 +591 -0
- package/README.md +320 -19
- package/package.json +6 -3
- package/src/apply-patches.ts +60 -0
- package/src/change-buffer.ts +77 -11
- package/src/channel.ts +174 -19
- package/src/client-contract.ts +81 -0
- package/src/client-frames.ts +175 -0
- package/src/client-heartbeat.ts +77 -0
- package/src/client-mutations.ts +114 -0
- package/src/client-topics.ts +54 -0
- package/src/client.ts +307 -273
- package/src/cursor.ts +7 -1
- package/src/errors.ts +193 -4
- package/src/frame-lanes.ts +58 -0
- package/src/hooks.ts +19 -5
- package/src/identity-map.ts +141 -0
- package/src/index.ts +96 -28
- package/src/json.ts +38 -1
- package/src/live-contract.ts +67 -0
- package/src/live-definition.ts +16 -11
- package/src/live-fanout.ts +150 -0
- package/src/live-query.ts +215 -268
- package/src/live-rows.ts +143 -0
- package/src/local-store.ts +86 -43
- package/src/nats-client.ts +132 -0
- package/src/nats-fake.ts +389 -344
- package/src/nats-jetstream.ts +21 -20
- package/src/nats-kv.ts +7 -7
- package/src/nats-lib-client.ts +210 -0
- package/src/nats-transport.ts +109 -138
- package/src/offline-queue.ts +146 -30
- package/src/pg-entity-row.ts +99 -31
- package/src/pg-replication.ts +84 -27
- package/src/pg-socket.ts +4 -1
- package/src/policy-gate.ts +13 -5
- package/src/presence.ts +76 -6
- package/src/query-hook.ts +56 -0
- package/src/query-window.ts +151 -0
- package/src/rebase.ts +68 -8
- package/src/replicator.ts +84 -11
- package/src/socket.ts +170 -14
- package/src/subscriber-gate.ts +209 -0
- package/src/subscription-book.ts +237 -0
- package/src/sync-auth.ts +124 -0
- package/src/sync-frames.ts +185 -0
- package/src/sync-listen.ts +73 -0
- package/src/sync-node.ts +284 -243
- package/src/sync-protocol.ts +115 -24
- package/src/sync-upgrade.ts +124 -0
- package/src/thundering-herd.ts +21 -0
- package/src/transport-env.ts +3 -3
- package/src/type-pins.ts +72 -0
- package/src/window-lock.ts +21 -0
- package/src/nats-commands.ts +0 -97
- package/src/nats-connection-fixture.ts +0 -105
- package/src/nats-connection.ts +0 -464
- package/src/nats-protocol.ts +0 -222
- package/src/nats-socket.ts +0 -236
- package/src/pg-connection-fixture.ts +0 -215
- package/src/pg-replication-fixture.ts +0 -261
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// The shared, pre-policy result window one query id is served from: how it is built, how it is
|
|
2
|
+
// read once for N subscribers, and how one that is known to be wrong is replaced. The authz
|
|
3
|
+
// decision is never here — `live-query.ts` owns that, once per subscriber, over what this returns.
|
|
4
|
+
|
|
5
|
+
import type { JsonValue, Row } from './json';
|
|
6
|
+
import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
|
|
7
|
+
import type { IncrementalMatcher, SubscriptionShape } from './matcher-bridge';
|
|
8
|
+
import { WindowLock } from './window-lock';
|
|
9
|
+
|
|
10
|
+
export interface QueryEntry {
|
|
11
|
+
readonly qid: string;
|
|
12
|
+
readonly definition: LiveQueryDefinition;
|
|
13
|
+
readonly input: JsonValue;
|
|
14
|
+
/** Told to the client on every snapshot: the identity scope its rows belong under. */
|
|
15
|
+
readonly rowEntity: string | null;
|
|
16
|
+
readonly shape: SubscriptionShape;
|
|
17
|
+
readonly matcher: IncrementalMatcher;
|
|
18
|
+
readonly subscribers: Map<string, LiveSubscription>;
|
|
19
|
+
/**
|
|
20
|
+
* The shared, *pre-policy* result window. One per query id, bounded by the query's `limit`, and
|
|
21
|
+
* the reason the matcher can run once for N subscribers: the read is shared, the authz is not.
|
|
22
|
+
*/
|
|
23
|
+
rows: readonly Row[];
|
|
24
|
+
lsn: string;
|
|
25
|
+
/**
|
|
26
|
+
* This window is known to have missed at least one change, so patching it would compound the
|
|
27
|
+
* error. Set when the change stream skipped a sequence and when a matcher reports it lost the
|
|
28
|
+
* window's tail; cleared by the read that replaces the rows. It is *not* a subscriber's desync —
|
|
29
|
+
* that is per socket, and this is the window every one of them shares.
|
|
30
|
+
*/
|
|
31
|
+
stale: boolean;
|
|
32
|
+
/** Serial lane over `rows`/`lsn`. Every fanout and every window assignment takes its turn here. */
|
|
33
|
+
readonly lock: WindowLock;
|
|
34
|
+
/** The read in flight, shared by every subscriber that arrives during it. `null` between reads. */
|
|
35
|
+
reading: Promise<SnapshotResult> | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createEntry(
|
|
39
|
+
qid: string,
|
|
40
|
+
definition: LiveQueryDefinition,
|
|
41
|
+
input: JsonValue,
|
|
42
|
+
matcher: IncrementalMatcher,
|
|
43
|
+
): QueryEntry {
|
|
44
|
+
return {
|
|
45
|
+
qid,
|
|
46
|
+
definition,
|
|
47
|
+
input,
|
|
48
|
+
// Resolved with the matcher, from the same build: `prepare` has already run, so a definition
|
|
49
|
+
// that compiles its shape per input can answer.
|
|
50
|
+
rowEntity: definition.rowEntity?.(input) ?? null,
|
|
51
|
+
shape: {
|
|
52
|
+
qid,
|
|
53
|
+
// The matcher knows the dependency set this *input* produced; `definition.entities` is the
|
|
54
|
+
// static declaration and can only be a superset of it. Preferring the matcher is what lets a
|
|
55
|
+
// definition built from a real query carry no static list at all.
|
|
56
|
+
entities: matcher.entities.length > 0 ? matcher.entities : definition.entities,
|
|
57
|
+
orgId: orgIdOf(input),
|
|
58
|
+
...(definition.columns ? { columns: definition.columns } : {}),
|
|
59
|
+
},
|
|
60
|
+
matcher,
|
|
61
|
+
subscribers: new Map(),
|
|
62
|
+
rows: [],
|
|
63
|
+
lsn: '',
|
|
64
|
+
stale: false,
|
|
65
|
+
lock: new WindowLock(),
|
|
66
|
+
reading: null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The window this subscriber is served from, read once per entry. A subscriber arriving while
|
|
72
|
+
* another's read is in flight joins that read rather than issuing its own — N cold subscribers on
|
|
73
|
+
* one query id being N reads is the shared window not existing.
|
|
74
|
+
*
|
|
75
|
+
* The result lands in the lane, and never backwards. A snapshot that resolved after a newer change
|
|
76
|
+
* had already been fanned out would rewind every later subscriber to rows the window has moved
|
|
77
|
+
* past, so a stale read is discarded and its caller is served from the newer window instead.
|
|
78
|
+
*/
|
|
79
|
+
export async function fillWindow(
|
|
80
|
+
entry: QueryEntry,
|
|
81
|
+
): Promise<{ rows: readonly Row[]; lsn: string }> {
|
|
82
|
+
// Read before `startRead` clears it: a second caller arriving during the read joins it and is
|
|
83
|
+
// not the one that forced it, which is what keeps one forced read from becoming N.
|
|
84
|
+
const forced = entry.stale;
|
|
85
|
+
const result = await (forced || entry.reading === null ? startRead(entry) : entry.reading);
|
|
86
|
+
return await entry.lock.run(async () => {
|
|
87
|
+
if (forced) {
|
|
88
|
+
// A forced read replaces the window whatever its lsn says: it was issued *because* what is
|
|
89
|
+
// under it is wrong, and a definition with no lsn provider answers `''` — which the
|
|
90
|
+
// never-backwards rule below would read as older than what we hold and discard, leaving
|
|
91
|
+
// every subscriber served from the window the gap already invalidated.
|
|
92
|
+
entry.rows = result.rows;
|
|
93
|
+
if (result.lsn > entry.lsn) entry.lsn = result.lsn;
|
|
94
|
+
} else if (result.lsn >= entry.lsn) {
|
|
95
|
+
entry.rows = result.rows;
|
|
96
|
+
entry.lsn = result.lsn;
|
|
97
|
+
}
|
|
98
|
+
return { rows: entry.rows, lsn: entry.lsn };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The same replacement, for a caller that is already holding the lane. A fanout cannot call
|
|
104
|
+
* `fillWindow` — that takes the entry's own lane, and a lane is not reentrant — so the one path
|
|
105
|
+
* that repairs a stale window mid-fanout is spelled here rather than deadlocking on the other.
|
|
106
|
+
*/
|
|
107
|
+
export async function refillWindowInLane(entry: QueryEntry): Promise<void> {
|
|
108
|
+
const result = await startRead(entry);
|
|
109
|
+
entry.rows = result.rows;
|
|
110
|
+
if (result.lsn > entry.lsn) entry.lsn = result.lsn;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Publishes the in-flight read, and clears it as it settles — the share is per read, not a cache. */
|
|
114
|
+
function startRead(entry: QueryEntry): Promise<SnapshotResult> {
|
|
115
|
+
// Cleared here rather than when the read lands: the read about to be issued is the one that
|
|
116
|
+
// answers the staleness, so a second caller must join it instead of forcing another.
|
|
117
|
+
entry.stale = false;
|
|
118
|
+
const reading = readSnapshot(entry);
|
|
119
|
+
entry.reading = reading;
|
|
120
|
+
const done = (): void => {
|
|
121
|
+
if (entry.reading === reading) entry.reading = null;
|
|
122
|
+
};
|
|
123
|
+
void reading.then(done, done);
|
|
124
|
+
return reading;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The definition's read, with the staleness put back when it does not answer.
|
|
129
|
+
*
|
|
130
|
+
* Clearing the mark on the way in and never restoring it was the gap repair happening once and
|
|
131
|
+
* never again: the snapshot that was going to replace an invalidated window rejects — the pool is
|
|
132
|
+
* exhausted by the same incident that caused the gap — and the entry is left unmarked over rows it
|
|
133
|
+
* is known to have missed a change on. Nothing re-reads, `#resnapshot` serves every desynced
|
|
134
|
+
* subscriber out of that divergent window and clears their marks, and the divergence `stale` exists
|
|
135
|
+
* to prevent is now permanent and silent. `async` so a definition that throws synchronously takes
|
|
136
|
+
* the same path as one that rejects.
|
|
137
|
+
*/
|
|
138
|
+
async function readSnapshot(entry: QueryEntry): Promise<SnapshotResult> {
|
|
139
|
+
try {
|
|
140
|
+
return await entry.definition.snapshot({ input: entry.input });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
entry.stale = true;
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function orgIdOf(input: JsonValue): string | null {
|
|
148
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) return null;
|
|
149
|
+
const value = input['orgId'];
|
|
150
|
+
return typeof value === 'string' ? value : null;
|
|
151
|
+
}
|
package/src/rebase.ts
CHANGED
|
@@ -108,26 +108,86 @@ export function reconcile<T extends TableMap = TableMap>(
|
|
|
108
108
|
const table = store.table(ack.entity);
|
|
109
109
|
const local = table.get(ack.id);
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
const { affected, rolledBack } = undoFrom(store, log, entry?.seq ?? 0);
|
|
112
|
+
|
|
113
|
+
const base = store.table(ack.entity).get(ack.id);
|
|
114
|
+
const winner = land(store, ack, strategy, { local, base }, options);
|
|
115
|
+
log.drop(ack.key);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
strategy: strategyName(strategy),
|
|
119
|
+
rolledBack,
|
|
120
|
+
reapplied: replayExcept(store, affected, ack.key),
|
|
121
|
+
winner,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Everything at or after `from` is optimistic, so it is undone newest-first — and `reconcile` and
|
|
127
|
+
* `rollbackMutation` are one rule with different middles, not two. Spelled twice, the next change
|
|
128
|
+
* to the replay order has to be made twice, and the half that is missed diverges silently.
|
|
129
|
+
*/
|
|
130
|
+
function undoFrom<T extends TableMap>(
|
|
131
|
+
store: LocalStore<T>,
|
|
132
|
+
log: RebaseLog<T>,
|
|
133
|
+
from: number,
|
|
134
|
+
): { affected: readonly RebaseEntry<T>[]; rolledBack: string[] } {
|
|
135
|
+
const affected = log.pending().filter((candidate) => candidate.seq >= from);
|
|
113
136
|
const rolledBack: string[] = [];
|
|
114
137
|
for (const candidate of [...affected].reverse()) {
|
|
115
138
|
store.rollback(candidate.key);
|
|
116
139
|
rolledBack.push(candidate.key);
|
|
117
140
|
}
|
|
141
|
+
return { affected, rolledBack };
|
|
142
|
+
}
|
|
118
143
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
144
|
+
/**
|
|
145
|
+
* The other half: replay in sequence order, skipping the one the server has now settled. `local` is
|
|
146
|
+
* pure, which is what makes replaying it deterministic and therefore safe to do at all.
|
|
147
|
+
*/
|
|
148
|
+
function replayExcept<T extends TableMap>(
|
|
149
|
+
store: LocalStore<T>,
|
|
150
|
+
affected: readonly RebaseEntry<T>[],
|
|
151
|
+
settled: string,
|
|
152
|
+
): string[] {
|
|
123
153
|
const reapplied: string[] = [];
|
|
124
154
|
for (const candidate of affected) {
|
|
125
|
-
if (candidate.key ===
|
|
155
|
+
if (candidate.key === settled) continue;
|
|
126
156
|
store.apply(candidate.key, (tx) => candidate.apply(tx));
|
|
127
157
|
reapplied.push(candidate.key);
|
|
128
158
|
}
|
|
159
|
+
return reapplied;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface RollbackResult {
|
|
163
|
+
readonly rolledBack: readonly string[];
|
|
164
|
+
readonly reapplied: readonly string[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The other half of `reconcile`: the server **refused** a mutation, so there is no server truth to
|
|
169
|
+
* land — only an optimistic write to take back. Same shape as a reconcile, and for the same reason:
|
|
170
|
+
* the writes made after it may depend on it, so everything from its sequence onward is undone
|
|
171
|
+
* newest-first and then replayed without it. Replay is deterministic because `local` is pure.
|
|
172
|
+
*
|
|
173
|
+
* Idempotent for a key the log does not hold: a denial can arrive twice, and tier 2 records nothing
|
|
174
|
+
* to undo in the first place.
|
|
175
|
+
*/
|
|
176
|
+
export function rollbackMutation<T extends TableMap = TableMap>(args: {
|
|
177
|
+
store: LocalStore<T>;
|
|
178
|
+
log: RebaseLog<T>;
|
|
179
|
+
key: string;
|
|
180
|
+
}): RollbackResult {
|
|
181
|
+
const { store, log, key } = args;
|
|
182
|
+
const entry = log.get(key);
|
|
183
|
+
if (!entry) return { rolledBack: [], reapplied: [] };
|
|
129
184
|
|
|
130
|
-
|
|
185
|
+
const { affected, rolledBack } = undoFrom(store, log, entry.seq);
|
|
186
|
+
// The one thing that differs from `reconcile`'s middle: there is no server truth to land. Dropped
|
|
187
|
+
// and never retried — a denial is a decision about this intent, so replaying it on the next
|
|
188
|
+
// reconcile would put the write the server refused back on the screen.
|
|
189
|
+
log.drop(key);
|
|
190
|
+
return { rolledBack, reapplied: replayExcept(store, affected, key) };
|
|
131
191
|
}
|
|
132
192
|
|
|
133
193
|
function land<T extends TableMap>(
|
package/src/replicator.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// its feed and reports `/readyz` false; it retries with jittered backoff and takes over the moment
|
|
12
12
|
// the holder dies. Scaling the replicator is therefore always vertical, and that is by design.
|
|
13
13
|
|
|
14
|
-
import { logger, withSpan } from '@ultimat3/core';
|
|
14
|
+
import { logger, uuid, withSpan } from '@ultimat3/core';
|
|
15
15
|
import type { ChangeEvent, ChangeFeed } from './changefeed';
|
|
16
16
|
import type { Transport } from './fanout';
|
|
17
17
|
import { type BackoffPolicy, backoffDelay, defaultBackoff, type Rng } from './thundering-herd';
|
|
@@ -72,6 +72,26 @@ export interface ReplicatorStats {
|
|
|
72
72
|
readonly outOfOrder: number;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* What the bus actually carries: one change, plus who published it and where in that publisher's
|
|
77
|
+
* stream it sits.
|
|
78
|
+
*
|
|
79
|
+
* Fanout is core NATS — `publish`, no ack, at most once — so a consumer that reads only the change
|
|
80
|
+
* cannot tell "nothing happened" from "eleven changes went past while I was reconnecting". An lsn
|
|
81
|
+
* cannot answer it either: a WAL position is a byte offset, so a legitimate next change is an
|
|
82
|
+
* arbitrary jump forwards. A per-publisher counter is the one number a gap is visible in.
|
|
83
|
+
*
|
|
84
|
+
* Both fields are optional on the wire, so a node reading a publisher that predates them simply
|
|
85
|
+
* detects nothing — the same rule the `snapshot.entity` field follows, on a subject no client sees.
|
|
86
|
+
*/
|
|
87
|
+
export interface ChangeEnvelope {
|
|
88
|
+
readonly change: ChangeEvent;
|
|
89
|
+
/** Monotonic within `producer`, from 1. `null` from a publisher that does not sequence. */
|
|
90
|
+
readonly seq: number | null;
|
|
91
|
+
/** Identifies one replicator *run*. A new one restarts `seq`, and that is not a gap. */
|
|
92
|
+
readonly producer: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
export interface Replicator {
|
|
76
96
|
/** `false` = another replicator holds the lock; this process must stay `/readyz` false. */
|
|
77
97
|
start(): Promise<boolean>;
|
|
@@ -91,6 +111,11 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
|
|
|
91
111
|
let published = 0;
|
|
92
112
|
let skipped = 0;
|
|
93
113
|
let outOfOrder = 0;
|
|
114
|
+
// One id per run, not per process: a replicator that took the lock back after a crash publishes
|
|
115
|
+
// from its persisted lsn, and a consumer must read that as a new stream rather than as a gap in
|
|
116
|
+
// the old one.
|
|
117
|
+
let producer = uuid();
|
|
118
|
+
let seq = 0;
|
|
94
119
|
|
|
95
120
|
const onChange = async (raw: ChangeEvent): Promise<void> => {
|
|
96
121
|
const change = normalize(raw);
|
|
@@ -104,7 +129,13 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
|
|
|
104
129
|
return;
|
|
105
130
|
}
|
|
106
131
|
await withSpan('realtime.replicate', async () => {
|
|
107
|
-
|
|
132
|
+
seq += 1;
|
|
133
|
+
// The envelope is the change plus two fields, flat, so a consumer that only knows about the
|
|
134
|
+
// change reads it unchanged — `parseChange` still answers on the same payload.
|
|
135
|
+
await options.transport.publish(
|
|
136
|
+
subjectOf(change),
|
|
137
|
+
JSON.stringify({ ...change, seq, producer }),
|
|
138
|
+
);
|
|
108
139
|
lastLsn = change.lsn;
|
|
109
140
|
published += 1;
|
|
110
141
|
});
|
|
@@ -118,6 +149,8 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
|
|
|
118
149
|
return false;
|
|
119
150
|
}
|
|
120
151
|
running = true;
|
|
152
|
+
producer = uuid();
|
|
153
|
+
seq = 0;
|
|
121
154
|
await options.feed.start(
|
|
122
155
|
options.from === undefined ? { onChange } : { from: options.from, onChange },
|
|
123
156
|
);
|
|
@@ -163,23 +196,63 @@ export function normalize(change: ChangeEvent): ChangeEvent | null {
|
|
|
163
196
|
|
|
164
197
|
/** Sync-node side of the bus: decode a published change back into a `ChangeEvent`. */
|
|
165
198
|
export function parseChange(payload: string): ChangeEvent | null {
|
|
199
|
+
return parseEnvelope(payload)?.change ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The same decode, keeping the two fields a gap is visible in. `parseChange` is this, narrowed. */
|
|
203
|
+
export function parseEnvelope(payload: string): ChangeEnvelope | null {
|
|
166
204
|
try {
|
|
167
205
|
const parsed: unknown = JSON.parse(payload);
|
|
168
206
|
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
169
|
-
const shape = parsed as Partial<ChangeEvent
|
|
207
|
+
const shape = parsed as Partial<ChangeEvent> & { seq?: unknown; producer?: unknown };
|
|
170
208
|
if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
|
|
171
209
|
if (shape.op !== 'insert' && shape.op !== 'update' && shape.op !== 'delete') return null;
|
|
172
210
|
return {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
211
|
+
change: {
|
|
212
|
+
entity: shape.entity,
|
|
213
|
+
op: shape.op,
|
|
214
|
+
before: shape.before ?? null,
|
|
215
|
+
after: shape.after ?? null,
|
|
216
|
+
lsn: shape.lsn,
|
|
217
|
+
txid: typeof shape.txid === 'string' ? shape.txid : '',
|
|
218
|
+
orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
|
|
219
|
+
at: typeof shape.at === 'number' ? shape.at : 0,
|
|
220
|
+
},
|
|
221
|
+
seq: typeof shape.seq === 'number' && Number.isFinite(shape.seq) ? shape.seq : null,
|
|
222
|
+
producer: typeof shape.producer === 'string' ? shape.producer : null,
|
|
181
223
|
};
|
|
182
224
|
} catch {
|
|
183
225
|
return null;
|
|
184
226
|
}
|
|
185
227
|
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The consume-side twin of the publisher's counter, and the only thing on a `sync` node that can
|
|
231
|
+
* say "this node missed changes". Publish-side duplicate and out-of-order guards already existed;
|
|
232
|
+
* there was no equivalent here, so a NATS blip during a rolling restart was eleven changes that
|
|
233
|
+
* simply never happened as far as every subscriber on this node could tell.
|
|
234
|
+
*
|
|
235
|
+
* Per producer, because a replicator restart legitimately rewinds the counter. A repeat or a
|
|
236
|
+
* reordering is *not* reported as a gap — the window's own lsn guard refuses those — and neither is
|
|
237
|
+
* the first message of a stream: a node that joined late has missed everything by definition, and
|
|
238
|
+
* every subscription it holds started after it did.
|
|
239
|
+
*/
|
|
240
|
+
export class SeqGapDetector {
|
|
241
|
+
readonly #next = new Map<string, number>();
|
|
242
|
+
|
|
243
|
+
/** `true` when at least one message between the last one and this one was never delivered. */
|
|
244
|
+
observe(envelope: ChangeEnvelope): boolean {
|
|
245
|
+
const { producer, seq } = envelope;
|
|
246
|
+
if (producer === null || seq === null) return false;
|
|
247
|
+
const expected = this.#next.get(producer);
|
|
248
|
+
// Never backwards: a redelivery must not lower the bar and turn the next legitimate message
|
|
249
|
+
// into a gap of its own.
|
|
250
|
+
this.#next.set(producer, Math.max(expected ?? 0, seq + 1));
|
|
251
|
+
return expected !== undefined && seq > expected;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Producers this node has read. Bounded by replicator restarts, so it is swept on a drain. */
|
|
255
|
+
forget(): void {
|
|
256
|
+
this.#next.clear();
|
|
257
|
+
}
|
|
258
|
+
}
|
package/src/socket.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
// One WS connection. Bun's WS profile is what makes a million sockets affordable, so this object
|
|
2
|
-
// is deliberately lean: ~8 fields
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// is deliberately lean: ~8 fields, two small sets and one token bucket (the frame budget, which is
|
|
3
|
+
// five numbers and the only thing standing between one authenticated socket and this node's whole
|
|
4
|
+
// subscribe path). Budget is ~1KB of JS heap per connection on top of Bun's own per-socket cost —
|
|
5
|
+
// anything richer (row caches, per-socket buffers) belongs in the transport or the change buffer,
|
|
6
|
+
// never here. `sync` is stateless: nothing on this object survives a restart, and nothing needs to.
|
|
6
7
|
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type Actor,
|
|
10
|
+
type Clock,
|
|
11
|
+
type Counter,
|
|
12
|
+
counter,
|
|
13
|
+
logger,
|
|
14
|
+
recordConnection,
|
|
15
|
+
systemClock,
|
|
16
|
+
uuid,
|
|
17
|
+
} from '@ultimat3/core';
|
|
8
18
|
import { encode, type Frame } from './sync-protocol';
|
|
19
|
+
import { AcceptBudget } from './thundering-herd';
|
|
9
20
|
|
|
10
21
|
export const CLOSE = {
|
|
11
22
|
normal: 1000,
|
|
@@ -17,7 +28,16 @@ export const CLOSE = {
|
|
|
17
28
|
drain: 4002,
|
|
18
29
|
} as const;
|
|
19
30
|
|
|
20
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* The slice of Bun's `ServerWebSocket` this package uses. Structural, so tests need no server.
|
|
33
|
+
*
|
|
34
|
+
* `subscribe`/`unsubscribe` are Bun's native pub/sub and this package does NOT use them: nothing
|
|
35
|
+
* here publishes to a native topic, and nothing will — a native publish cannot be refused per
|
|
36
|
+
* socket, cannot report the frame it dropped and cannot mark a subscriber desynced, which is
|
|
37
|
+
* exactly what `SocketRegistry.deliver` and `SyncSocket.send` exist to do. They are still declared
|
|
38
|
+
* because `WsLike` is the slice of Bun's own object, and an app already implements it; deleting
|
|
39
|
+
* them is a separate, breaking edit to every implementer.
|
|
40
|
+
*/
|
|
21
41
|
export interface WsLike {
|
|
22
42
|
send(data: string): number;
|
|
23
43
|
close(code?: number, reason?: string): void;
|
|
@@ -37,8 +57,47 @@ export interface SyncSocketOptions {
|
|
|
37
57
|
/** Frames are dropped rather than queued past this. See `desynced`. */
|
|
38
58
|
readonly maxBufferedBytes?: number;
|
|
39
59
|
readonly maxDroppedFrames?: number;
|
|
60
|
+
/** Sustained inbound frames this socket may have routed per second. See `frameBudget`. */
|
|
61
|
+
readonly maxFramesPerSecond?: number;
|
|
62
|
+
/** Burst allowance, so a client subscribing its whole cap at connect is never refused. */
|
|
63
|
+
readonly frameBurst?: number;
|
|
40
64
|
}
|
|
41
65
|
|
|
66
|
+
/**
|
|
67
|
+
* What one socket may ask this node to do per second, and how much of it may arrive at once.
|
|
68
|
+
*
|
|
69
|
+
* The burst clears `DEFAULT_MAX_PER_SOCKET` (128) plus a `hello`, because that is exactly what a
|
|
70
|
+
* legitimate client sends on connect; the sustained rate is well under the ~155 frames/s measured
|
|
71
|
+
* to consume a node through the subscribe path's amplifiers.
|
|
72
|
+
*/
|
|
73
|
+
export const DEFAULT_MAX_FRAMES_PER_SECOND = 64;
|
|
74
|
+
export const DEFAULT_FRAME_BURST = 256;
|
|
75
|
+
/**
|
|
76
|
+
* Queued-but-unwritten bytes on one server socket before `send` declines and marks the subscriber
|
|
77
|
+
* desynced. `sync-node.ts` hands the same number to Bun as `backpressureLimit` rather than spelling
|
|
78
|
+
* it again: they are one socket's one buffer, and a check the runtime's own limit fires before is a
|
|
79
|
+
* check that never runs. The client half (`client-mutations.ts`) is deliberately its own constant —
|
|
80
|
+
* it is browser code, and importing this file would pull the node's socket registry into the tab.
|
|
81
|
+
*/
|
|
82
|
+
export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Channel frames this process dropped under backpressure. A DATA-LOSS counter, not a saturation
|
|
86
|
+
* one: the live-query path repairs a dropped patch (the subscriber is marked desynced and the next
|
|
87
|
+
* change re-snapshots it), and a channel has no cursor, no mark and no re-snapshot — so this is the
|
|
88
|
+
* only trace a lost channel message leaves anywhere.
|
|
89
|
+
*
|
|
90
|
+
* Declared here rather than in `@ultimat3/core`'s `runtime-metrics.ts` because that file is the
|
|
91
|
+
* series EVERY Ultimate process emits and the deploy chart scales on; this one exists only where
|
|
92
|
+
* channels do. **No attributes**: a topic is client-chosen (`topic()` admits any
|
|
93
|
+
* `[A-Za-z0-9_-]+` segment), so a per-topic label is an unbounded series count one socket can mint
|
|
94
|
+
* — the topic goes in the log line, where cardinality is somebody else's index.
|
|
95
|
+
*/
|
|
96
|
+
const channelFramesDropped: Counter = counter('channel_frames_dropped_total', {
|
|
97
|
+
unit: '{frame}',
|
|
98
|
+
description: 'Channel frames dropped by socket backpressure — unrecoverable, nothing replays one',
|
|
99
|
+
});
|
|
100
|
+
|
|
42
101
|
export function actorIdOf(actor: Actor | null): string | null {
|
|
43
102
|
return actor === null ? null : actor.id;
|
|
44
103
|
}
|
|
@@ -58,6 +117,13 @@ export class SyncSocket {
|
|
|
58
117
|
* flush re-snapshots instead of silently diverging.
|
|
59
118
|
*/
|
|
60
119
|
readonly desynced = new Set<string>();
|
|
120
|
+
/**
|
|
121
|
+
* Inbound frames this socket may still have routed. The accept budget spends one token per
|
|
122
|
+
* UPGRADE, so nothing bounded what happened after: one authenticated socket reached a DB read,
|
|
123
|
+
* a shared-store presence write and a fleet-wide publish once per frame, unbounded and
|
|
124
|
+
* unawaited. Same token bucket, one per socket — the mechanism already existed, one rung down.
|
|
125
|
+
*/
|
|
126
|
+
readonly frameBudget: AcceptBudget;
|
|
61
127
|
|
|
62
128
|
actor: Actor | null;
|
|
63
129
|
lastSeenAt: number;
|
|
@@ -77,8 +143,13 @@ export class SyncSocket {
|
|
|
77
143
|
this.clientBuildId = options.clientBuildId;
|
|
78
144
|
this.serverBuildId = options.serverBuildId;
|
|
79
145
|
this.actor = options.actor ?? null;
|
|
80
|
-
this.#maxBufferedBytes = options.maxBufferedBytes ??
|
|
146
|
+
this.#maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
|
81
147
|
this.#maxDroppedFrames = options.maxDroppedFrames ?? 32;
|
|
148
|
+
this.frameBudget = new AcceptBudget({
|
|
149
|
+
perSecond: options.maxFramesPerSecond ?? DEFAULT_MAX_FRAMES_PER_SECOND,
|
|
150
|
+
burst: options.frameBurst ?? DEFAULT_FRAME_BURST,
|
|
151
|
+
clock: this.#clock,
|
|
152
|
+
});
|
|
82
153
|
this.openedAt = this.#clock.now().getTime();
|
|
83
154
|
this.lastSeenAt = this.openedAt;
|
|
84
155
|
}
|
|
@@ -120,14 +191,18 @@ export class SyncSocket {
|
|
|
120
191
|
this.desynced.delete(sid);
|
|
121
192
|
}
|
|
122
193
|
|
|
194
|
+
/**
|
|
195
|
+
* This socket's own membership, and nothing else. It used to also call Bun's `ws.subscribe`,
|
|
196
|
+
* which built a second per-topic index nothing ever published to — the fanout is
|
|
197
|
+
* `SocketRegistry.deliver`, one filtered `send` per socket, because that is the only path that
|
|
198
|
+
* can count a dropped frame or close a socket that is drowning in them.
|
|
199
|
+
*/
|
|
123
200
|
subscribeTopic(topic: string): void {
|
|
124
201
|
this.topics.add(topic);
|
|
125
|
-
this.#ws.subscribe(topic);
|
|
126
202
|
}
|
|
127
203
|
|
|
128
204
|
unsubscribeTopic(topic: string): void {
|
|
129
205
|
this.topics.delete(topic);
|
|
130
|
-
this.#ws.unsubscribe(topic);
|
|
131
206
|
}
|
|
132
207
|
|
|
133
208
|
touch(): void {
|
|
@@ -154,8 +229,18 @@ export interface SocketRegistryOptions {
|
|
|
154
229
|
/** Per-node socket table. Intentionally the only in-memory map on a `sync` node. */
|
|
155
230
|
export class SocketRegistry {
|
|
156
231
|
readonly #sockets = new Map<string, SyncSocket>();
|
|
232
|
+
/**
|
|
233
|
+
* Who is on each channel topic. `deliver` walked every socket on the node asking each whether
|
|
234
|
+
* it held the topic, so one message with one legitimate subscriber cost as many iterations as
|
|
235
|
+
* this node has connections — 50,000 at the scale this framework benchmarks. It lives here
|
|
236
|
+
* rather than on the hub because this is the only object that sees a socket die: a close, a
|
|
237
|
+
* drain and the idle sweep all pass through `remove`, and an index nobody cleans on those paths
|
|
238
|
+
* retains a dead socket per topic forever.
|
|
239
|
+
*/
|
|
240
|
+
readonly #byTopic = new Map<string, Set<SyncSocket>>();
|
|
157
241
|
readonly #clock: Clock;
|
|
158
242
|
readonly #idleTimeoutMs: number;
|
|
243
|
+
#droppedChannelFrames = 0;
|
|
159
244
|
|
|
160
245
|
constructor(options: SocketRegistryOptions = {}) {
|
|
161
246
|
this.#clock = options.clock ?? systemClock;
|
|
@@ -178,8 +263,39 @@ export class SocketRegistry {
|
|
|
178
263
|
}
|
|
179
264
|
|
|
180
265
|
remove(id: string): void {
|
|
266
|
+
const socket = this.#sockets.get(id);
|
|
181
267
|
// `Map.delete` answers "was it actually there", so a double close cannot decrement twice.
|
|
182
|
-
if (this.#sockets.delete(id))
|
|
268
|
+
if (!this.#sockets.delete(id)) return;
|
|
269
|
+
recordConnection(-1);
|
|
270
|
+
if (!socket) return;
|
|
271
|
+
// Leaving this table IS the close, whoever noticed first. Bun's `close` callback reports a
|
|
272
|
+
// connection that has already gone, so nothing called `close()` on this object and
|
|
273
|
+
// `socket.closed` stayed false — leaving a subscribe still awaiting its snapshot read with no
|
|
274
|
+
// way to tell that the socket it is about to attach to was torn down while it read.
|
|
275
|
+
socket.close(CLOSE.goingAway, 'connection closed');
|
|
276
|
+
for (const name of socket.topics) this.#dropFrom(name, socket);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Join a topic: the socket's own set, Bun's native pub/sub and this node's delivery index, in
|
|
281
|
+
* one call. `ChannelHub` is its only caller, and calls nothing else — two call sites for one
|
|
282
|
+
* membership is how an index goes wrong.
|
|
283
|
+
*/
|
|
284
|
+
joinTopic(socket: SyncSocket, topic: string): void {
|
|
285
|
+
socket.subscribeTopic(topic);
|
|
286
|
+
const members = this.#byTopic.get(topic);
|
|
287
|
+
if (members) members.add(socket);
|
|
288
|
+
else this.#byTopic.set(topic, new Set([socket]));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
leaveTopic(socket: SyncSocket, topic: string): void {
|
|
292
|
+
socket.unsubscribeTopic(topic);
|
|
293
|
+
this.#dropFrom(topic, socket);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Sockets this node would deliver `topic` to. */
|
|
297
|
+
subscriberCount(topic: string): number {
|
|
298
|
+
return this.#byTopic.get(topic)?.size ?? 0;
|
|
183
299
|
}
|
|
184
300
|
|
|
185
301
|
get(id: string): SyncSocket | undefined {
|
|
@@ -209,13 +325,53 @@ export class SocketRegistry {
|
|
|
209
325
|
return closed;
|
|
210
326
|
}
|
|
211
327
|
|
|
212
|
-
/**
|
|
213
|
-
*
|
|
328
|
+
/**
|
|
329
|
+
* Local delivery for a channel topic — every channel message on this node comes through here,
|
|
330
|
+
* so it reads the per-topic index rather than the socket table. A socket that closed without
|
|
331
|
+
* an unsubscribe is dropped as it is met, so the index cannot outlive the connection even on a
|
|
332
|
+
* path that forgot to `remove` it.
|
|
333
|
+
*/
|
|
214
334
|
deliver(topic: string, frame: Frame): number {
|
|
335
|
+
const members = this.#byTopic.get(topic);
|
|
336
|
+
if (!members) return 0;
|
|
215
337
|
let sent = 0;
|
|
216
|
-
|
|
217
|
-
|
|
338
|
+
let dropped = 0;
|
|
339
|
+
for (const socket of members) {
|
|
340
|
+
if (socket.closed) {
|
|
341
|
+
members.delete(socket);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (socket.send(frame)) sent += 1;
|
|
345
|
+
else dropped += 1;
|
|
346
|
+
}
|
|
347
|
+
if (members.size === 0) this.#byTopic.delete(topic);
|
|
348
|
+
if (dropped > 0) {
|
|
349
|
+
this.#droppedChannelFrames += dropped;
|
|
350
|
+
// Two readers, one event, one spelling: the series an operator alerts on and the line that
|
|
351
|
+
// says which topic it was. `deliver` ignored `send`'s answer and so did the hub above it, so
|
|
352
|
+
// until both existed a lost channel message left no trace at all.
|
|
353
|
+
channelFramesDropped.add(dropped);
|
|
354
|
+
logger.warn('channel.frames_dropped', { topic, dropped, total: this.#droppedChannelFrames });
|
|
218
355
|
}
|
|
219
356
|
return sent;
|
|
220
357
|
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Channel frames backpressure refused since boot, node-wide and cumulative — the in-process read
|
|
361
|
+
* of `channel_frames_dropped_total`, for a test or a benchmark that cannot scrape.
|
|
362
|
+
*
|
|
363
|
+
* Node-wide on purpose: a socket past `maxDroppedFrames` is closed and removed, so a per-socket
|
|
364
|
+
* count leaves with the socket exactly when loss is worst. Distinct from `SyncSocket.droppedFrames`,
|
|
365
|
+
* which counts every kind of frame one connection lost, channel and live-query patch alike.
|
|
366
|
+
*/
|
|
367
|
+
get droppedChannelFrames(): number {
|
|
368
|
+
return this.#droppedChannelFrames;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#dropFrom(topic: string, socket: SyncSocket): void {
|
|
372
|
+
const members = this.#byTopic.get(topic);
|
|
373
|
+
if (!members) return;
|
|
374
|
+
members.delete(socket);
|
|
375
|
+
if (members.size === 0) this.#byTopic.delete(topic);
|
|
376
|
+
}
|
|
221
377
|
}
|