@ultimat3/realtime 20.2.1 → 22.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 +300 -952
- package/README.md +192 -131
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +14 -1
- package/src/channel-authz.ts +52 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +155 -0
- package/src/channel-describe.ts +35 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +134 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +87 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +359 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +150 -195
- package/src/cursor.ts +7 -2
- package/src/errors.ts +55 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +133 -0
- package/src/idb-types.ts +48 -0
- package/src/index.ts +80 -75
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +15 -4
- package/src/live-fanout.ts +81 -6
- package/src/live-query.ts +11 -0
- package/src/live-record-type.ts +19 -0
- package/src/live-replicator.ts +160 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +324 -0
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +85 -39
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +312 -0
- package/src/page-socket.ts +139 -0
- package/src/page-store.ts +138 -0
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +28 -8
- package/src/pg-wire.ts +51 -15
- package/src/pgoutput.ts +37 -2
- package/src/policy-fake.ts +14 -0
- package/src/presence.ts +17 -9
- package/src/query-window.ts +38 -21
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +20 -4
- package/src/server.ts +10 -11
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +344 -0
- package/src/socket-host.ts +225 -0
- package/src/socket-idle.ts +21 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +91 -49
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +26 -114
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +31 -12
- package/src/transport-env.ts +55 -14
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +227 -0
- package/src/use-query.ts +260 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The engine ⇄ tab seam: the messages a `MessagePort` carries, the part of a port the engine uses,
|
|
2
|
+
// and what the engine remembers about each attached one. Shared by the engine and its router.
|
|
3
|
+
|
|
4
|
+
import type { SyncTarget } from './page-store';
|
|
5
|
+
import type { SubscribeFrame } from './sync-protocol';
|
|
6
|
+
|
|
7
|
+
/** The engine ⇄ tab messages. `frame.data` is a wire frame, encoded exactly as a socket carries it. */
|
|
8
|
+
export type PortMessage =
|
|
9
|
+
| { readonly t: 'open'; readonly target: SyncTarget }
|
|
10
|
+
| { readonly t: 'frame'; readonly data: string }
|
|
11
|
+
| { readonly t: 'close'; readonly code: number }
|
|
12
|
+
/** The tab is going away (`pagehide`, a principal change): release the port itself. */
|
|
13
|
+
| { readonly t: 'bye' };
|
|
14
|
+
|
|
15
|
+
/** The part of a `MessagePort` the engine uses — so a test hands in a `MessageChannel` port. */
|
|
16
|
+
export interface PortLike {
|
|
17
|
+
postMessage(message: PortMessage): void;
|
|
18
|
+
onmessage: ((event: { readonly data: unknown }) => void) | null;
|
|
19
|
+
close?(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One tab, as the engine knows it. */
|
|
23
|
+
export interface AttachedPort {
|
|
24
|
+
readonly id: number;
|
|
25
|
+
readonly port: PortLike;
|
|
26
|
+
/** The tab asked for its virtual socket to be open. */
|
|
27
|
+
open: boolean;
|
|
28
|
+
/** It has asked before: a later `open` is the tab's own retry, not a page arriving. */
|
|
29
|
+
asked: boolean;
|
|
30
|
+
lastSeen: number;
|
|
31
|
+
/** Channel topics this port wants. */
|
|
32
|
+
readonly topics: Set<string>;
|
|
33
|
+
/** Engine sid → the add frame, for the live queries this port holds. */
|
|
34
|
+
readonly lives: Map<string, SubscribeFrame>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A real `MessagePort` as a `PortLike`. Setting `onmessage` on a `MessagePort` starts it, which is
|
|
39
|
+
* what both hosts rely on; the wrapper exists only because the DOM types a handler over
|
|
40
|
+
* `MessageEvent` and the engine reads nothing of one but `data`.
|
|
41
|
+
*/
|
|
42
|
+
export function messagePort(port: MessagePort): PortLike {
|
|
43
|
+
let handler: PortLike['onmessage'] = null;
|
|
44
|
+
return {
|
|
45
|
+
postMessage: (message) => port.postMessage(message),
|
|
46
|
+
get onmessage() {
|
|
47
|
+
return handler;
|
|
48
|
+
},
|
|
49
|
+
set onmessage(next) {
|
|
50
|
+
handler = next;
|
|
51
|
+
port.onmessage = next === null ? null : (event: MessageEvent) => next({ data: event.data });
|
|
52
|
+
},
|
|
53
|
+
close: () => port.close(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// The engine's multiplexing: which port wants which channel topic and which live query, so the
|
|
2
|
+
// one socket carries ONE membership per topic and every server frame is ROUTED only to the ports
|
|
3
|
+
// that want it — never broadcast. It holds no socket; the engine hands it `send` and `post`.
|
|
4
|
+
|
|
5
|
+
import { CHANNEL_SID_PREFIX } from './client-channels';
|
|
6
|
+
import type { AttachedPort, PortMessage } from './socket-port';
|
|
7
|
+
import { encode, type Frame, PROTOCOL_VERSION, type SubscribeFrame } from './sync-protocol';
|
|
8
|
+
|
|
9
|
+
export interface RouterDeps {
|
|
10
|
+
/** To the node, when the socket is up. */
|
|
11
|
+
send(frame: Frame): void;
|
|
12
|
+
post(attached: AttachedPort, message: PortMessage): void;
|
|
13
|
+
port(id: number): AttachedPort | undefined;
|
|
14
|
+
ports(): Iterable<AttachedPort>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The frames the router routes; the engine keeps `hello`, `update-available` and `reconnect`. */
|
|
18
|
+
type RoutedFrame = Extract<
|
|
19
|
+
Frame,
|
|
20
|
+
{ readonly type: 'snapshot' | 'patch' | 'ack' | 'records' | 'replay-gap' | 'events' }
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
export class PortRouter {
|
|
24
|
+
readonly #deps: RouterDeps;
|
|
25
|
+
/**
|
|
26
|
+
* Topic → the ports that want it, the add frame that joined it, and the epoch the node last
|
|
27
|
+
* named for it — what a later port is told to re-read in.
|
|
28
|
+
*/
|
|
29
|
+
readonly #topics = new Map<
|
|
30
|
+
string,
|
|
31
|
+
{ readonly ports: Set<number>; readonly add: SubscribeFrame; epoch: string | null }
|
|
32
|
+
>();
|
|
33
|
+
|
|
34
|
+
constructor(deps: RouterDeps) {
|
|
35
|
+
this.#deps = deps;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A tab's `subscribe`: a channel membership, or a live query under a port-scoped sid. */
|
|
39
|
+
subscribe(attached: AttachedPort, frame: SubscribeFrame): void {
|
|
40
|
+
if (frame.target.kind === 'channel') {
|
|
41
|
+
this.#channel(attached, frame);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (frame.target.kind !== 'query') return;
|
|
45
|
+
// Every tab mints its own sids; the engine's sid carries the port so two tabs never collide.
|
|
46
|
+
const sid = `${attached.id}|${frame.sid}`;
|
|
47
|
+
const rewritten: SubscribeFrame = { ...frame, sid };
|
|
48
|
+
if (frame.op === 'add') attached.lives.set(sid, rewritten);
|
|
49
|
+
else attached.lives.delete(sid);
|
|
50
|
+
this.#deps.send(rewritten);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Release what a port held: its channel wants and its live registrations. */
|
|
54
|
+
release(attached: AttachedPort): void {
|
|
55
|
+
for (const topic of attached.topics) {
|
|
56
|
+
const add = this.#topics.get(topic)?.add;
|
|
57
|
+
if (add !== undefined) this.#leave(attached.id, topic, { ...add, op: 'drop' });
|
|
58
|
+
}
|
|
59
|
+
attached.topics.clear();
|
|
60
|
+
for (const add of attached.lives.values()) this.#deps.send({ ...add, op: 'drop' });
|
|
61
|
+
attached.lives.clear();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One server frame to the ports it belongs to. `data` is the frame as the socket carried it. */
|
|
65
|
+
route(frame: RoutedFrame, data: string): void {
|
|
66
|
+
switch (frame.type) {
|
|
67
|
+
case 'snapshot':
|
|
68
|
+
case 'patch':
|
|
69
|
+
this.#toLive(frame.sid, (sid) => ({ ...frame, sid }));
|
|
70
|
+
return;
|
|
71
|
+
case 'ack': {
|
|
72
|
+
const ref = frame.ref;
|
|
73
|
+
if (ref.includes('|')) this.#toLive(ref, (local) => ({ ...frame, ref: local }));
|
|
74
|
+
else if (this.#topics.has(ref)) this.#toTopic(ref, data);
|
|
75
|
+
else for (const attached of this.#deps.ports()) this.#frame(attached, data);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
case 'records':
|
|
79
|
+
case 'replay-gap': {
|
|
80
|
+
const held = this.#topics.get(`${CHANNEL_SID_PREFIX}${frame.channel}`);
|
|
81
|
+
if (held !== undefined) held.epoch = frame.epoch;
|
|
82
|
+
this.#toTopic(`${CHANNEL_SID_PREFIX}${frame.channel}`, data);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
case 'events':
|
|
86
|
+
this.#toTopic(`${CHANNEL_SID_PREFIX}${frame.channel}`, data);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Re-announcing each channel IS the node's presence heartbeat — with no `since`, so it replays
|
|
93
|
+
* nothing.
|
|
94
|
+
*/
|
|
95
|
+
announce(): void {
|
|
96
|
+
for (const { add } of this.#topics.values()) this.#deps.send(withoutSince(add));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The socket is gone: every membership goes with it; each tab resubscribes from its cursors. */
|
|
100
|
+
clear(): void {
|
|
101
|
+
this.#topics.clear();
|
|
102
|
+
for (const attached of this.#deps.ports()) {
|
|
103
|
+
attached.topics.clear();
|
|
104
|
+
attached.lives.clear();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One membership per topic across every port; the first `add` joins, the last leave drops. */
|
|
109
|
+
#channel(attached: AttachedPort, frame: SubscribeFrame): void {
|
|
110
|
+
const topic = frame.sid;
|
|
111
|
+
const held = this.#topics.get(topic);
|
|
112
|
+
if (frame.op !== 'add') {
|
|
113
|
+
attached.topics.delete(topic);
|
|
114
|
+
this.#leave(attached.id, topic, frame);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
attached.topics.add(topic);
|
|
118
|
+
if (held === undefined) {
|
|
119
|
+
this.#topics.set(topic, { ports: new Set([attached.id]), add: frame, epoch: null });
|
|
120
|
+
this.#deps.send(frame);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (held.ports.has(attached.id)) return;
|
|
124
|
+
held.ports.add(attached.id);
|
|
125
|
+
// The node never hears this add, so it cannot answer it with the `replay-gap` a fresh seat
|
|
126
|
+
// gets; the engine does. With no epoch yet, the node's own answer is still on its way and now
|
|
127
|
+
// reaches this port too.
|
|
128
|
+
if (held.epoch === null) return;
|
|
129
|
+
const gap: Frame = {
|
|
130
|
+
type: 'replay-gap',
|
|
131
|
+
v: PROTOCOL_VERSION,
|
|
132
|
+
channel: topic.slice(CHANNEL_SID_PREFIX.length),
|
|
133
|
+
epoch: held.epoch,
|
|
134
|
+
};
|
|
135
|
+
this.#frame(attached, encode(gap));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#leave(id: number, topic: string, drop: SubscribeFrame): void {
|
|
139
|
+
const held = this.#topics.get(topic);
|
|
140
|
+
if (held === undefined) return;
|
|
141
|
+
held.ports.delete(id);
|
|
142
|
+
if (held.ports.size > 0) return;
|
|
143
|
+
this.#topics.delete(topic);
|
|
144
|
+
this.#deps.send(drop);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#toLive(engineSid: string, rewrite: (localSid: string) => Frame): void {
|
|
148
|
+
const bar = engineSid.indexOf('|');
|
|
149
|
+
const attached = this.#deps.port(Number(engineSid.slice(0, bar)));
|
|
150
|
+
if (attached !== undefined) this.#frame(attached, encode(rewrite(engineSid.slice(bar + 1))));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#toTopic(topic: string, data: string): void {
|
|
154
|
+
for (const id of this.#topics.get(topic)?.ports ?? []) {
|
|
155
|
+
const attached = this.#deps.port(id);
|
|
156
|
+
if (attached !== undefined) this.#frame(attached, data);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#frame(attached: AttachedPort, data: string): void {
|
|
161
|
+
this.#deps.post(attached, { t: 'frame', data });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A channel's `add` without its resume point — the beat repeats a membership, it resumes nothing. */
|
|
166
|
+
function withoutSince(add: SubscribeFrame): SubscribeFrame {
|
|
167
|
+
if (add.target.kind !== 'channel') return add;
|
|
168
|
+
const { since: _since, ...target } = add.target;
|
|
169
|
+
return { ...add, target };
|
|
170
|
+
}
|
package/src/socket.ts
CHANGED
|
@@ -16,7 +16,11 @@ import {
|
|
|
16
16
|
systemClock,
|
|
17
17
|
uuid,
|
|
18
18
|
} from '@ultimat3/core';
|
|
19
|
+
import { GapRepairs } from './channel-gaps';
|
|
20
|
+
import type { ChannelRecordsFrame } from './channel-wire';
|
|
19
21
|
import { CLOSE } from './close-codes';
|
|
22
|
+
import { DropWindow } from './socket-drops';
|
|
23
|
+
import { DEFAULT_IDLE_TIMEOUT_MS } from './socket-idle';
|
|
20
24
|
import { encode, type Frame } from './sync-protocol';
|
|
21
25
|
import { AcceptBudget } from './thundering-herd';
|
|
22
26
|
|
|
@@ -81,10 +85,10 @@ export const DEFAULT_FRAME_BURST = 256;
|
|
|
81
85
|
export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
82
86
|
|
|
83
87
|
/**
|
|
84
|
-
* Channel frames this process dropped under backpressure. A
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* only
|
|
88
|
+
* Channel frames this process dropped under backpressure. A dropped `records` frame is now marked
|
|
89
|
+
* and REPAIRED — the socket is sent `replay-gap` once it drains and re-reads the channel's catch-up
|
|
90
|
+
* query (`channel_replay_gaps_total` counts those) — while a dropped `events` frame is ephemeral
|
|
91
|
+
* by definition and is only counted. The two series side by side are the whole delivery story.
|
|
88
92
|
*
|
|
89
93
|
* Declared here rather than in `@ultimat3/core`'s `runtime-metrics.ts` because that file is the
|
|
90
94
|
* series EVERY Ultimate process emits and the deploy chart scales on; this one exists only where
|
|
@@ -94,7 +98,7 @@ export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
|
94
98
|
*/
|
|
95
99
|
const channelFramesDropped: Counter = counter('channel_frames_dropped_total', {
|
|
96
100
|
unit: '{frame}',
|
|
97
|
-
description: 'Channel frames dropped by socket backpressure
|
|
101
|
+
description: 'Channel frames dropped by socket backpressure',
|
|
98
102
|
});
|
|
99
103
|
|
|
100
104
|
export function actorIdOf(actor: Actor | null): string | null {
|
|
@@ -123,6 +127,12 @@ export class SyncSocket {
|
|
|
123
127
|
* flush re-snapshots instead of silently diverging.
|
|
124
128
|
*/
|
|
125
129
|
readonly desynced = new Set<string>();
|
|
130
|
+
/**
|
|
131
|
+
* Channel topics whose `records` stream this socket lost a frame of, with the epoch it was lost
|
|
132
|
+
* in. The channel twin of `desynced`: written on a drop, read on the next delivery or drain, which
|
|
133
|
+
* sends `replay-gap` and clears it. Bounded by `topics`, so it adds nothing a socket did not hold.
|
|
134
|
+
*/
|
|
135
|
+
readonly gaps = new Map<string, string>();
|
|
126
136
|
/**
|
|
127
137
|
* Inbound frames this socket may still have routed. The accept budget spends one token per
|
|
128
138
|
* UPGRADE, so nothing bounded what happened after: one authenticated socket reached a DB read,
|
|
@@ -148,6 +158,7 @@ export class SyncSocket {
|
|
|
148
158
|
readonly #maxDroppedFrames: number;
|
|
149
159
|
#clientBuildId: string;
|
|
150
160
|
#closed = false;
|
|
161
|
+
readonly #drops: DropWindow;
|
|
151
162
|
|
|
152
163
|
constructor(options: SyncSocketOptions) {
|
|
153
164
|
this.#ws = options.ws;
|
|
@@ -160,6 +171,7 @@ export class SyncSocket {
|
|
|
160
171
|
finiteOption('SyncSocket', 'maxBufferedBytes', this.#maxBufferedBytes);
|
|
161
172
|
this.#maxDroppedFrames = options.maxDroppedFrames ?? 32;
|
|
162
173
|
finiteOption('SyncSocket', 'maxDroppedFrames', this.#maxDroppedFrames);
|
|
174
|
+
this.#drops = new DropWindow(this.#maxDroppedFrames);
|
|
163
175
|
this.frameBudget = new AcceptBudget({
|
|
164
176
|
perSecond: finiteOption(
|
|
165
177
|
'SyncSocket',
|
|
@@ -207,33 +219,34 @@ export class SyncSocket {
|
|
|
207
219
|
|
|
208
220
|
/** `false` means the frame was dropped by backpressure — the caller must mark state stale. */
|
|
209
221
|
send(frame: Frame): boolean {
|
|
222
|
+
return this.sendEncoded(encode(frame));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** An `encode()`d frame — the fan-outs encode once for every socket. Adds no validation. */
|
|
226
|
+
sendEncoded(text: string): boolean {
|
|
210
227
|
if (this.#closed) return false;
|
|
211
228
|
if (this.#ws.getBufferedAmount() > this.#maxBufferedBytes) {
|
|
212
|
-
this
|
|
213
|
-
if (this.droppedFrames > this.#maxDroppedFrames) {
|
|
214
|
-
this.close(CLOSE.overloaded, 'backpressure');
|
|
215
|
-
}
|
|
229
|
+
this.#dropped();
|
|
216
230
|
return false;
|
|
217
231
|
}
|
|
218
|
-
// `
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// the exact outcome every other `socket.send` on this node reads its answer to prevent.
|
|
224
|
-
if (this.#ws.send(encode(frame)) <= 0) {
|
|
225
|
-
this.droppedFrames += 1;
|
|
226
|
-
// The same ceiling backpressure takes: a socket the runtime keeps refusing is one to close,
|
|
227
|
-
// and the two are one failure — the write went nowhere either way.
|
|
228
|
-
if (this.droppedFrames > this.#maxDroppedFrames) {
|
|
229
|
-
this.close(CLOSE.overloaded, 'backpressure');
|
|
230
|
-
}
|
|
232
|
+
// Bun answers `0` for a message it DROPPED (the socket closed after the check above) — the one
|
|
233
|
+
// drop. `-1` means QUEUED under backpressure, and it is delivered: counted as a drop it sent a
|
|
234
|
+
// spurious `replay-gap`, desynced a healthy subscriber, and closed the socket after 33.
|
|
235
|
+
if (this.#ws.send(text) === 0) {
|
|
236
|
+
this.#dropped();
|
|
231
237
|
return false;
|
|
232
238
|
}
|
|
233
239
|
this.sentFrames += 1;
|
|
234
240
|
return true;
|
|
235
241
|
}
|
|
236
242
|
|
|
243
|
+
/** One frame that never left: counted for life, judged per `DROP_WINDOW_MS` (`socket-drops.ts`). */
|
|
244
|
+
#dropped(): void {
|
|
245
|
+
this.droppedFrames += 1;
|
|
246
|
+
if (this.#drops.overflowed(this.#clock.monotonic()))
|
|
247
|
+
this.close(CLOSE.overloaded, 'backpressure');
|
|
248
|
+
}
|
|
249
|
+
|
|
237
250
|
/** Record a dropped or invalidated subscription so the next flush re-snapshots it. */
|
|
238
251
|
markDesynced(sid: string): void {
|
|
239
252
|
this.desynced.add(sid);
|
|
@@ -255,6 +268,7 @@ export class SyncSocket {
|
|
|
255
268
|
|
|
256
269
|
unsubscribeTopic(topic: string): void {
|
|
257
270
|
this.topics.delete(topic);
|
|
271
|
+
this.gaps.delete(topic);
|
|
258
272
|
}
|
|
259
273
|
|
|
260
274
|
touch(): void {
|
|
@@ -273,25 +287,6 @@ export class SyncSocket {
|
|
|
273
287
|
}
|
|
274
288
|
}
|
|
275
289
|
|
|
276
|
-
/**
|
|
277
|
-
* How long a socket may route no frame before `sync-node` evicts it. It is an APPLICATION
|
|
278
|
-
* inactivity budget and not Bun's transport one: Bun's `idleTimeout` is renewed by its own
|
|
279
|
-
* ping/pong, so a client whose TCP stack still answers pings while its frame loop is wedged holds
|
|
280
|
-
* its grant, its subscriptions and its topic membership forever. A beating client sends a `hello`
|
|
281
|
-
* every `DEFAULT_HEARTBEAT_MS` (15s), so this is eight missed beats.
|
|
282
|
-
*/
|
|
283
|
-
export const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* How often to ask. A quarter of the budget, floored at a second: a socket is evicted within 25%
|
|
287
|
-
* of its window of going quiet, and a node holding 50,000 of them pays one pass over the table
|
|
288
|
-
* four times per window rather than once a second. Derived rather than configured — a second knob
|
|
289
|
-
* is a second number that can disagree with the one it is a fraction of.
|
|
290
|
-
*/
|
|
291
|
-
export function idleSweepPeriodMs(idleTimeoutMs: number): number {
|
|
292
|
-
return Math.max(1_000, Math.floor(idleTimeoutMs / 4));
|
|
293
|
-
}
|
|
294
|
-
|
|
295
290
|
export interface SocketRegistryOptions {
|
|
296
291
|
readonly clock?: Clock;
|
|
297
292
|
/** Bun's own `idleTimeout` is renewed by its ping/pong; this budget counts routed FRAMES. */
|
|
@@ -313,6 +308,8 @@ export class SocketRegistry {
|
|
|
313
308
|
readonly #clock: Clock;
|
|
314
309
|
readonly #idleTimeoutMs: number;
|
|
315
310
|
#droppedChannelFrames = 0;
|
|
311
|
+
/** The repair side of `SyncSocket.gaps`: `repairAll(socket)` is what Bun's `drain` calls. */
|
|
312
|
+
readonly gapRepairs = new GapRepairs();
|
|
316
313
|
|
|
317
314
|
constructor(options: SocketRegistryOptions = {}) {
|
|
318
315
|
this.#clock = options.clock ?? systemClock;
|
|
@@ -414,6 +411,9 @@ export class SocketRegistry {
|
|
|
414
411
|
deliver(topic: string, frame: Frame): number {
|
|
415
412
|
const members = this.#byTopic.get(topic);
|
|
416
413
|
if (!members) return 0;
|
|
414
|
+
// Encoded ONCE for every member: per socket it was 16.9 ms against 0.6 ms for a 2 kB frame to
|
|
415
|
+
// 10,000 sockets, measured.
|
|
416
|
+
const text = encode(frame);
|
|
417
417
|
let sent = 0;
|
|
418
418
|
let dropped = 0;
|
|
419
419
|
for (const socket of members) {
|
|
@@ -421,21 +421,63 @@ export class SocketRegistry {
|
|
|
421
421
|
members.delete(socket);
|
|
422
422
|
continue;
|
|
423
423
|
}
|
|
424
|
-
if (socket.
|
|
424
|
+
if (socket.sendEncoded(text)) sent += 1;
|
|
425
425
|
else dropped += 1;
|
|
426
426
|
}
|
|
427
427
|
if (members.size === 0) this.#byTopic.delete(topic);
|
|
428
|
-
if (dropped > 0)
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
428
|
+
if (dropped > 0) this.#countDropped(topic, dropped);
|
|
429
|
+
return sent;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* A `records` delivery. An owed `replay-gap` goes out first; a socket that then drops the frame is
|
|
434
|
+
* marked for the next one. `deliver`'s twin, because only a records stream can be repaired.
|
|
435
|
+
*/
|
|
436
|
+
deliverRecords(topic: string, epoch: string, frame: ChannelRecordsFrame): number {
|
|
437
|
+
const members = this.#byTopic.get(topic);
|
|
438
|
+
if (!members) return 0;
|
|
439
|
+
const text = encode(frame);
|
|
440
|
+
let sent = 0;
|
|
441
|
+
let dropped = 0;
|
|
442
|
+
for (const socket of members) {
|
|
443
|
+
if (socket.closed) {
|
|
444
|
+
members.delete(socket);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
this.gapRepairs.repair(socket, topic);
|
|
448
|
+
if (socket.sendEncoded(text)) {
|
|
449
|
+
sent += 1;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
dropped += 1;
|
|
453
|
+
socket.gaps.set(topic, epoch);
|
|
435
454
|
}
|
|
455
|
+
if (members.size === 0) this.#byTopic.delete(topic);
|
|
456
|
+
if (dropped > 0) this.#countDropped(topic, dropped);
|
|
436
457
|
return sent;
|
|
437
458
|
}
|
|
438
459
|
|
|
460
|
+
/** Every member owes a re-read (a TRUNCATE): one `replay-gap` each now, or kept as a mark. */
|
|
461
|
+
announceGap(topic: string, epoch: string): number {
|
|
462
|
+
const members = this.#byTopic.get(topic);
|
|
463
|
+
if (!members) return 0;
|
|
464
|
+
let announced = 0;
|
|
465
|
+
for (const socket of members) {
|
|
466
|
+
if (socket.closed) continue;
|
|
467
|
+
socket.gaps.set(topic, epoch);
|
|
468
|
+
if (this.gapRepairs.repair(socket, topic)) announced += 1;
|
|
469
|
+
}
|
|
470
|
+
return announced;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#countDropped(topic: string, dropped: number): void {
|
|
474
|
+
this.#droppedChannelFrames += dropped;
|
|
475
|
+
// Two readers, one event, one spelling: the series an operator alerts on and the line that
|
|
476
|
+
// says which topic it was.
|
|
477
|
+
channelFramesDropped.add(dropped);
|
|
478
|
+
logger.warn('channel.frames_dropped', { topic, dropped, total: this.#droppedChannelFrames });
|
|
479
|
+
}
|
|
480
|
+
|
|
439
481
|
/**
|
|
440
482
|
* Channel frames backpressure refused since boot, node-wide and cumulative — the in-process read
|
|
441
483
|
* of `channel_frames_dropped_total`, for a test or a benchmark that cannot scrape.
|
package/src/subscriber-gate.ts
CHANGED
|
@@ -54,6 +54,55 @@ export interface GateTarget {
|
|
|
54
54
|
readonly rows: readonly Row[];
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The shared window, indexed ONCE per fan-out rather than searched per subscriber per patch: a
|
|
59
|
+
* `rows.find` inside the subscriber loop was O(subscribers × window) — 54.6 ms against 0.7 ms at
|
|
60
|
+
* 1000 subscribers over a 500-row window. `windowIndex` builds it; `filterPatches` builds its own
|
|
61
|
+
* when a caller passes none.
|
|
62
|
+
*/
|
|
63
|
+
export interface WindowIndex {
|
|
64
|
+
readonly byId: ReadonlyMap<string, Row>;
|
|
65
|
+
readonly position: ReadonlyMap<string, number>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function windowIndex(rows: readonly Row[]): WindowIndex {
|
|
69
|
+
const byId = new Map<string, Row>();
|
|
70
|
+
const position = new Map<string, number>();
|
|
71
|
+
rows.forEach((row, at) => {
|
|
72
|
+
byId.set(row.id, row);
|
|
73
|
+
position.set(row.id, at);
|
|
74
|
+
});
|
|
75
|
+
return { byId, position };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What a subscriber holds as a patch list is folded: the cursor's set, plus what this list has
|
|
80
|
+
* inserted, minus what it has deleted — never a copy of the set per subscriber.
|
|
81
|
+
*/
|
|
82
|
+
class Holding {
|
|
83
|
+
readonly #base: ReadonlySet<string>;
|
|
84
|
+
readonly #added = new Set<string>();
|
|
85
|
+
readonly #removed = new Set<string>();
|
|
86
|
+
|
|
87
|
+
constructor(base: ReadonlySet<string>) {
|
|
88
|
+
this.#base = base;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
has(id: string): boolean {
|
|
92
|
+
return this.#added.has(id) || (this.#base.has(id) && !this.#removed.has(id));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fold(patch: RowPatch): void {
|
|
96
|
+
if (patch.op === 'delete') {
|
|
97
|
+
this.#added.delete(patch.id);
|
|
98
|
+
this.#removed.add(patch.id);
|
|
99
|
+
} else if (patch.op === 'insert') {
|
|
100
|
+
this.#removed.delete(patch.id);
|
|
101
|
+
this.#added.add(patch.id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
57
106
|
export interface SubscriberGateOptions {
|
|
58
107
|
/**
|
|
59
108
|
* `live.rows_denied`. A row an actor's policy refuses is dropped, never sent and never turned
|
|
@@ -113,11 +162,16 @@ export class SubscriberGate {
|
|
|
113
162
|
who: Subscriber,
|
|
114
163
|
patches: readonly RowPatch[],
|
|
115
164
|
held: ReadonlySet<string>,
|
|
165
|
+
index: WindowIndex = windowIndex(target.rows),
|
|
116
166
|
): Promise<RowPatch[]> {
|
|
117
167
|
const out: RowPatch[] = [];
|
|
168
|
+
const holding = new Holding(held);
|
|
118
169
|
for (const patch of patches) {
|
|
119
|
-
const allowed = await this
|
|
120
|
-
if (allowed
|
|
170
|
+
const allowed = await this.#decide(target, who, patch, holding.has(patch.id), index);
|
|
171
|
+
if (allowed === null) continue;
|
|
172
|
+
const placed = rebase(allowed, holding, target.rows, index);
|
|
173
|
+
holding.fold(placed);
|
|
174
|
+
out.push(placed);
|
|
121
175
|
}
|
|
122
176
|
return out;
|
|
123
177
|
}
|
|
@@ -128,6 +182,16 @@ export class SubscriberGate {
|
|
|
128
182
|
who: Subscriber,
|
|
129
183
|
patch: RowPatch,
|
|
130
184
|
holds: boolean,
|
|
185
|
+
): Promise<RowPatch | null> {
|
|
186
|
+
return await this.#decide(target, who, patch, holds, windowIndex(target.rows));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async #decide(
|
|
190
|
+
target: GateTarget,
|
|
191
|
+
who: Subscriber,
|
|
192
|
+
patch: RowPatch,
|
|
193
|
+
holds: boolean,
|
|
194
|
+
index: WindowIndex,
|
|
131
195
|
): Promise<RowPatch | null> {
|
|
132
196
|
// A delete carries no row, so there is nothing to put in front of the rule — `holds` IS the
|
|
133
197
|
// decision, the same one the two branches below take for a row a rule has just refused.
|
|
@@ -147,7 +211,7 @@ export class SubscriberGate {
|
|
|
147
211
|
this.#denied(target.qid, who, patch.id);
|
|
148
212
|
return null;
|
|
149
213
|
}
|
|
150
|
-
const full =
|
|
214
|
+
const full = index.byId.get(patch.id);
|
|
151
215
|
// No whole row means no decision to take. An update patch carries the changed columns only, so
|
|
152
216
|
// a rule reading `row.ownerId` on one reads `undefined` and answers as if the row had said so —
|
|
153
217
|
// fail-closed for `=== actor.id`, and a leak for every `!row.private`. It is not a gate that
|
|
@@ -218,6 +282,31 @@ const actorIdOf = (who: Subscriber): string | null => (who.actor === null ? null
|
|
|
218
282
|
* window stopped holding it. Written once so the two paths cannot answer differently: a client left
|
|
219
283
|
* holding the row instead renders a revoked grant until something else reconnects it.
|
|
220
284
|
*/
|
|
285
|
+
/**
|
|
286
|
+
* The patch as this subscriber must read it. `index` was a position in the SHARED, pre-policy
|
|
287
|
+
* window, and forwarded unchanged it placed the row out of order for anyone who sees fewer rows —
|
|
288
|
+
* and its size told them how many rows they may not see sit ahead of it. Re-based on the rows ahead
|
|
289
|
+
* of it that this subscriber holds; dropped from a delete, which the client applies by id. Bounded
|
|
290
|
+
* by `CURSOR_ID_LIMIT` like `holds` is: a held row past it is not counted.
|
|
291
|
+
*/
|
|
292
|
+
function rebase(
|
|
293
|
+
patch: RowPatch,
|
|
294
|
+
holding: Holding,
|
|
295
|
+
rows: readonly Row[],
|
|
296
|
+
index: WindowIndex,
|
|
297
|
+
): RowPatch {
|
|
298
|
+
if (patch.index === undefined) return patch;
|
|
299
|
+
const { index: _shared, ...rest } = patch;
|
|
300
|
+
if (patch.op === 'delete' || patch.row === null) return rest;
|
|
301
|
+
const at = index.position.get(patch.id) ?? patch.index;
|
|
302
|
+
let local = 0;
|
|
303
|
+
for (let i = 0; i < at && i < rows.length; i += 1) {
|
|
304
|
+
const id = rows[i]?.id;
|
|
305
|
+
if (id !== undefined && id !== patch.id && holding.has(id)) local += 1;
|
|
306
|
+
}
|
|
307
|
+
return { ...rest, index: local };
|
|
308
|
+
}
|
|
309
|
+
|
|
221
310
|
const withdrawn = (patch: RowPatch): RowPatch => ({
|
|
222
311
|
op: 'delete',
|
|
223
312
|
id: patch.id,
|
package/src/sync-auth.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Who a socket is, and for how long. The `sync` node evaluates no credential of its own — an app
|
|
2
|
-
// supplies `authenticate
|
|
3
|
-
//
|
|
2
|
+
// supplies `authenticate` — so this file owns the shape of that answer, the per-node book that
|
|
3
|
+
// holds it, and the pass that re-decides one whose window has closed.
|
|
4
4
|
|
|
5
5
|
import type { Actor, Clock } from '@ultimat3/core';
|
|
6
6
|
|