@ultimat3/realtime 2.0.0 → 3.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 +50 -0
- package/README.md +17 -1
- package/package.json +3 -3
- package/src/channel.ts +32 -5
- package/src/index.ts +3 -0
- package/src/query-window.ts +52 -16
- package/src/socket.ts +55 -20
- package/src/sync-node.ts +41 -6
package/CLAUDE.md
CHANGED
|
@@ -79,6 +79,21 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
79
79
|
prevent. A lane that fails now desyncs its own subscribers and the first failure still reaches
|
|
80
80
|
the caller, but it costs one query id. The lane chains on a settled shadow of each task: one
|
|
81
81
|
fanout that threw must not reject every fanout behind it.
|
|
82
|
+
- **Two reads of one entry are ordered by a READ GENERATION, never by an lsn.** `QueryEntry.lsn`
|
|
83
|
+
is optional — a definition with no lsn provider answers `''` from every snapshot — so the
|
|
84
|
+
never-backwards rule expressed purely in lsn terms read `'' >= ''` as "newer" and let the older
|
|
85
|
+
of two concurrent reads land on top of the newer one's window. The interleaving: a cold
|
|
86
|
+
subscriber issues P1; the change stream skips a sequence and `registry.invalidate()` marks the
|
|
87
|
+
entry; a second cold subscriber forces P2, which **clears `stale` on the way in**; P2 lands with
|
|
88
|
+
the post-gap rows; P1 lands last and overwrites them. `stale` is false, so `fanoutChange`'s
|
|
89
|
+
repair never fires, the next change patches the pre-gap window and re-snapshots every desynced
|
|
90
|
+
subscriber out of it — permanently stale on a healthy socket, which is the exact outcome `stale`
|
|
91
|
+
exists to prevent. `entry.generation` is bumped in `startRead` and `entry.applied` records the
|
|
92
|
+
newest read whose rows are on the window: an *identity* check, the same one `startRead` makes on
|
|
93
|
+
`entry.reading` one function down and `packages/cache/src/single-flight.ts:70` makes for the same
|
|
94
|
+
reason. The lsn guard stays beside it for the other question — a read that resolved behind a
|
|
95
|
+
*change* the fanout already folded — because those are two orderings and neither answers the
|
|
96
|
+
other.
|
|
82
97
|
- **The definition's read is once per entry, not once per subscriber.** A cold subscriber arriving
|
|
83
98
|
while another's read is in flight joins that read — N cold subscribers on one query id being N
|
|
84
99
|
reads is the shared window not existing. It is a share, not a cache: the in-flight promise is
|
|
@@ -141,6 +156,34 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
141
156
|
could reach a different one than the container it is standing in for. The KV bucket and the
|
|
142
157
|
presence TTL come back with the transport for the same reason: they are one decision.
|
|
143
158
|
- `sync` is stateless: no sticky sessions, nothing on a socket survives a restart.
|
|
159
|
+
- **A socket the node evicts ITSELF is released through `teardown`, never through
|
|
160
|
+
`sockets.remove`.** Bun's `close` callback runs `teardown`; a drain and the idle sweep have no
|
|
161
|
+
callback behind them — Bun's fires a tick later and `sockets.get` misses by then — so whatever
|
|
162
|
+
they do instead *is* the whole release. `drain()` inlined three of `teardown`'s five steps
|
|
163
|
+
(`close`, `sockets.remove`, `grants.delete`) and skipped the two the rest of the fleet can see:
|
|
164
|
+
`registry.unsubscribeSocket` and `presence.leave` per topic. What that left is a `QueryEntry`
|
|
165
|
+
whose `subscribers` map never empties — matcher, shared window and `WindowLock` pinned, and
|
|
166
|
+
`source.forget(qid)` never called — and, worse because it is cross-node, a presence member every
|
|
167
|
+
other node renders for a full TTL. During a **rolling restart** that is every room showing each
|
|
168
|
+
user twice for up to 30s, beside the same client's reconnection under a new socket id. One
|
|
169
|
+
`evict(socket, code, reason)`, and every path that ends a socket without a callback takes it.
|
|
170
|
+
- **The idle sweep exists, is armed by `start()`, and its budget is an APPLICATION one.**
|
|
171
|
+
`SocketRegistry.sweepIdle` had no caller for as long as it existed, so `touch()`, `idleFor` and
|
|
172
|
+
the 120s default decided nothing and `idleTimeoutMs` was unreachable from `createSyncNode`. The
|
|
173
|
+
only live guard was `websocket.idleTimeout: 120` handed to Bun — which Bun's own ping/pong
|
|
174
|
+
renews, so a client whose frame loop is wedged answers pings and keeps its `GrantBook` entry,
|
|
175
|
+
its `SubscriptionBook` entries and its `#byTopic` membership forever. It is now
|
|
176
|
+
`SocketRegistry.idle()`, a **query**: this table is three of the five things a socket holds, so
|
|
177
|
+
the object that can evict one is the node and not the registry. `start()` arms one `.unref()`ed
|
|
178
|
+
pass every `idleSweepPeriodMs(idleTimeoutMs)` — a quarter of the budget, floored at a second,
|
|
179
|
+
derived rather than configured because a second knob is a second number that can disagree with
|
|
180
|
+
the one it is a fraction of — and `release()` clears it beside the presence sweep. **It measures
|
|
181
|
+
on `Clock.monotonic()`**, the clock `AcceptBudget` already uses: the sweep compares a DURATION,
|
|
182
|
+
and a duration read off `now().getTime()` is decided by whatever NTP last wrote — a step forward
|
|
183
|
+
evicts every socket that is talking, a step backward makes `idleFor` negative and spares every
|
|
184
|
+
socket that is dead, and the sweep had only just gained its first caller when both became
|
|
185
|
+
reachable. The field is named `lastSeenMonotonicMs` so nobody hands it to `new Date()`;
|
|
186
|
+
`openedAt` is the wall-clock one and stays that way, because a human reads it.
|
|
144
187
|
- **`drain()` and `stop()` both release what `start()` acquired, and releasing twice is a no-op.**
|
|
145
188
|
A `drain()` is terminal on its own — it closes the hub and evicts every socket — and nothing
|
|
146
189
|
obliges a `stop()` to follow it, so leaving the change subscription and the presence sweep to
|
|
@@ -321,6 +364,13 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
321
364
|
`Bridge` comment describes, one state earlier. So `close()` sets `#closed` **before** the walk and
|
|
322
365
|
`#open` closes its own subscription when it lands after one, dropping the entry with it so a
|
|
323
366
|
second post-close subscribe opens and closes its own rather than double-unsubscribing this handle.
|
|
367
|
+
It then **raises** `X_TRANSPORT_UNAVAILABLE` rather than returning: returning let `subscribe` fall
|
|
368
|
+
through to `joinTopic`, so the socket became a member of a topic nothing on this node is bridged
|
|
369
|
+
to — silent for the life of the connection, no error on either side, and no reason for the client
|
|
370
|
+
to redial. Reachable between `hub.close()` inside `node.drain()` and the last in-flight subscribe.
|
|
371
|
+
`#release` takes the bridge the caller reserved for the same reason: after `close()` cleared the
|
|
372
|
+
table, that topic name may hold a bridge a LATER subscribe opened, and releasing by name alone
|
|
373
|
+
decrements somebody else's refcount.
|
|
324
374
|
- Deny by default on topics. No guard = `X_TOPIC_FORBIDDEN`.
|
|
325
375
|
- **A guard that FAILS is not a guard that denied — the hub's copy of the rule the row gate already
|
|
326
376
|
follows.** On `onActorChange` (the re-auth pass) only a denial unsubscribes; anything else keeps
|
package/README.md
CHANGED
|
@@ -168,6 +168,7 @@ wire.
|
|
|
168
168
|
| distinct channel topics per node | 10,000 | `new ChannelHub({ maxTopicsPerNode })` | `X_SUBSCRIPTION_LIMIT` |
|
|
169
169
|
| outbound bytes buffered on one socket | 1 MiB | `createSyncNode({ maxBufferedBytes })` | the frame is dropped and `send` answers `false` |
|
|
170
170
|
| dropped frames before that socket is closed | 32 | `createSyncNode({ maxDroppedFrames })` | close `1013` (`overloaded`), reason `backpressure` |
|
|
171
|
+
| time one socket may route no frame | 120s | `createSyncNode({ idleTimeoutMs })` | close `4001` (`idle`), reason `idle timeout` |
|
|
171
172
|
| retained patch bytes per node | 64 MiB | `new RingChangeBuffer({ maxBytes, maxBytesPerQuery })` | eviction, then a re-snapshot on resume |
|
|
172
173
|
| array lengths and `input` nesting in a frame | `FRAME_LIMITS` | none — a hard ceiling | `X_PROTOCOL_VERSION` |
|
|
173
174
|
|
|
@@ -362,6 +363,18 @@ wire twice by a reconnect that raced an ack.
|
|
|
362
363
|
revoked grant — every topic on every re-authenticated socket, silently, with the client never told
|
|
363
364
|
to resubscribe. The initial `subscribe` is deliberately not split that way: there is no
|
|
364
365
|
subscription to keep, so a guard that raises refuses that subscribe and the client hears about it.
|
|
366
|
+
- **An idle socket is swept, and the sweep is an APPLICATION budget, not Bun's.** Bun's own
|
|
367
|
+
`idleTimeout` is renewed by its ping/pong, so a client whose frame loop is wedged answers pings
|
|
368
|
+
and keeps its grant, its live subscriptions and its topic membership indefinitely. `start()`
|
|
369
|
+
arms one `.unref()`ed pass every `idleTimeoutMs / 4` (floored at a second, derived rather than
|
|
370
|
+
configured) and evicts anything past the budget the same way a close does — through the node's
|
|
371
|
+
`teardown`, never `SocketRegistry.remove`. `SocketRegistry.idle()` is a *query* for that reason:
|
|
372
|
+
the socket table is three of the five things a socket holds, and the other two are its live
|
|
373
|
+
subscriptions and its presence membership on the shared set. `sweepIdle` — which closed and
|
|
374
|
+
removed here, and had no caller at all — is gone. The budget is measured on `Clock.monotonic()`,
|
|
375
|
+
so `SyncSocket.lastSeenMonotonicMs` is a duration's start and not an instant: an NTP step forward
|
|
376
|
+
would otherwise evict every socket that is talking, and a step backward would spare every socket
|
|
377
|
+
that is dead. `openedAt` stays on the wall clock — it is a value a human reads.
|
|
365
378
|
- **A `sync` node shuts down in two phases.** The `accept` phase calls `stopAccepting()`: `/readyz`
|
|
366
379
|
answers 503 and a late upgrade is shed with `retry-after-ms`, while **every socket the node holds
|
|
367
380
|
keeps its patch stream**. The `close` phase is `drain()` then `stop()`. Registered with no phase it
|
|
@@ -381,7 +394,10 @@ wire twice by a reconnect that raced an ack.
|
|
|
381
394
|
caller.
|
|
382
395
|
- **A cold subscribe reads once per query id.** Subscribers arriving during a read join it and each
|
|
383
396
|
runs its own policy pass over the result. A read that resolves behind a change already fanned out
|
|
384
|
-
is discarded rather than written back: the window only ever moves forwards.
|
|
397
|
+
is discarded rather than written back: the window only ever moves forwards. Two reads are ordered
|
|
398
|
+
by a monotonic **read generation** and never by lsn — a definition with no lsn provider answers
|
|
399
|
+
`''` for every read, and `'' >= ''` let the older of two concurrent reads land on top of the
|
|
400
|
+
newer one's gap repair, with `stale` already cleared and therefore nothing left to re-read.
|
|
385
401
|
- **A denial drops a row; a gate that could not decide does not.** A policy answer (`X_FORBIDDEN`,
|
|
386
402
|
`X_UNAUTHENTICATED`) is a decision and costs the row, counted as `rowsDenied`. Anything else a
|
|
387
403
|
gate throws — a rule whose lookup timed out, a predicate with a typo — is counted as
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/query": "
|
|
35
|
+
"@ultimat3/core": "3.0.0",
|
|
36
|
+
"@ultimat3/query": "3.0.0",
|
|
37
37
|
"nats": "2.29.3"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/channel.ts
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
import { type Actor, logger, renderThrowable } from '@ultimat3/core';
|
|
8
8
|
import { formatLsn } from './changefeed';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
isPolicyDenial,
|
|
11
|
+
SubscriptionLimitError,
|
|
12
|
+
TopicForbiddenError,
|
|
13
|
+
TransportUnavailableError,
|
|
14
|
+
} from './errors';
|
|
10
15
|
import { subjectMatches, type Transport, type TransportSubscription } from './fanout';
|
|
11
16
|
import type { JsonObject } from './json';
|
|
12
17
|
import type { SocketRegistry, SyncSocket } from './socket';
|
|
@@ -152,8 +157,11 @@ export class ChannelHub {
|
|
|
152
157
|
await this.#authorize(socket.actor, name);
|
|
153
158
|
await this.#open(name, bridge);
|
|
154
159
|
} catch (error) {
|
|
155
|
-
// The slot this subscribe took, given back on the one path that will never fill it
|
|
156
|
-
this
|
|
160
|
+
// The slot this subscribe took, given back on the one path that will never fill it — and
|
|
161
|
+
// given back to the bridge this subscribe actually reserved. `close()` clears the table, so
|
|
162
|
+
// a later subscribe may have put a DIFFERENT bridge under this name in the meantime, and
|
|
163
|
+
// decrementing that one's refs releases a topic somebody else is holding.
|
|
164
|
+
this.#release(name, bridge);
|
|
157
165
|
throw error;
|
|
158
166
|
} finally {
|
|
159
167
|
const held = this.#claimed.get(socket) ?? 1;
|
|
@@ -164,7 +172,7 @@ export class ChannelHub {
|
|
|
164
172
|
// membership this socket's close will give back, so the reference taken above has to go now or
|
|
165
173
|
// it is a bridge nothing will ever release.
|
|
166
174
|
if (socket.topics.has(name)) {
|
|
167
|
-
this.#release(name);
|
|
175
|
+
this.#release(name, bridge);
|
|
168
176
|
return;
|
|
169
177
|
}
|
|
170
178
|
// Through the registry, never `socket.subscribeTopic` directly: membership and the index the
|
|
@@ -297,12 +305,31 @@ export class ChannelHub {
|
|
|
297
305
|
if (this.#closed) {
|
|
298
306
|
unsubscribeWhenOpen(bridge);
|
|
299
307
|
if (this.#bridges.get(name) === bridge) this.#bridges.delete(name);
|
|
308
|
+
// RAISED, not returned. Returning let `subscribe` fall through to `joinTopic`, so the socket
|
|
309
|
+
// became a member of a topic nothing on this node is bridged to: silent for the life of the
|
|
310
|
+
// connection, with no error on either side and nothing telling the client to redial. The
|
|
311
|
+
// same refusal the transport itself answers when it is gone, because from the client's side
|
|
312
|
+
// that is what happened — this node's bus for that topic is closed.
|
|
313
|
+
throw new TransportUnavailableError({
|
|
314
|
+
transport: 'channel',
|
|
315
|
+
reason: `the hub closed while "${name}" was opening`,
|
|
316
|
+
// The reader is a browser websocket client, which cannot run a CLI — so pure command
|
|
317
|
+
// advice would be worse than prose here. The shape that satisfies axiom 4 anyway is
|
|
318
|
+
// `http/src/error-map.ts`'s: a command that SHIPS (`x errors explain`, unlike the planned
|
|
319
|
+
// `x logs tail`) for whoever is holding a terminal, then the instruction as a comment.
|
|
320
|
+
fix: 'x errors explain X_TRANSPORT_UNAVAILABLE --json # then reconnect and resubscribe: this node is draining',
|
|
321
|
+
});
|
|
300
322
|
}
|
|
301
323
|
}
|
|
302
324
|
|
|
303
|
-
|
|
325
|
+
/**
|
|
326
|
+
* `expected` is the bridge the caller reserved. Without it a release looks the topic up by name,
|
|
327
|
+
* and after a `close()` cleared the table that name may hold a bridge a LATER subscribe opened.
|
|
328
|
+
*/
|
|
329
|
+
#release(name: Topic, expected?: Bridge): void {
|
|
304
330
|
const bridge = this.#bridges.get(name);
|
|
305
331
|
if (!bridge) return;
|
|
332
|
+
if (expected !== undefined && bridge !== expected) return;
|
|
306
333
|
bridge.refs -= 1;
|
|
307
334
|
if (bridge.refs > 0) return;
|
|
308
335
|
unsubscribeWhenOpen(bridge);
|
package/src/index.ts
CHANGED
|
@@ -263,6 +263,7 @@ export {
|
|
|
263
263
|
createEntry,
|
|
264
264
|
fillWindow,
|
|
265
265
|
orgIdOf,
|
|
266
|
+
type PendingRead,
|
|
266
267
|
type QueryEntry,
|
|
267
268
|
refillWindowInLane,
|
|
268
269
|
} from './query-window';
|
|
@@ -299,8 +300,10 @@ export {
|
|
|
299
300
|
actorIdOf,
|
|
300
301
|
CLOSE,
|
|
301
302
|
DEFAULT_FRAME_BURST,
|
|
303
|
+
DEFAULT_IDLE_TIMEOUT_MS,
|
|
302
304
|
DEFAULT_MAX_BUFFERED_BYTES,
|
|
303
305
|
DEFAULT_MAX_FRAMES_PER_SECOND,
|
|
306
|
+
idleSweepPeriodMs,
|
|
304
307
|
SocketRegistry,
|
|
305
308
|
type SocketRegistryOptions,
|
|
306
309
|
SyncSocket,
|
package/src/query-window.ts
CHANGED
|
@@ -32,7 +32,24 @@ export interface QueryEntry {
|
|
|
32
32
|
/** Serial lane over `rows`/`lsn`. Every fanout and every window assignment takes its turn here. */
|
|
33
33
|
readonly lock: WindowLock;
|
|
34
34
|
/** The read in flight, shared by every subscriber that arrives during it. `null` between reads. */
|
|
35
|
-
reading:
|
|
35
|
+
reading: PendingRead | null;
|
|
36
|
+
/**
|
|
37
|
+
* Reads issued against this entry, ever. It is the ORDER of two reads, which nothing else here
|
|
38
|
+
* can answer: a definition with no lsn provider returns `''` from every snapshot.
|
|
39
|
+
*/
|
|
40
|
+
generation: number;
|
|
41
|
+
/** The generation of the newest read whose rows are in `rows`. `0` before the first one lands. */
|
|
42
|
+
applied: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One read and which read it is. They are one fact — a joiner needs the promise AND the generation
|
|
47
|
+
* it will have to compare against when it lands — and two fields on the entry is two writes a later
|
|
48
|
+
* edit can separate.
|
|
49
|
+
*/
|
|
50
|
+
export interface PendingRead {
|
|
51
|
+
readonly generation: number;
|
|
52
|
+
readonly result: Promise<SnapshotResult>;
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
export function createEntry(
|
|
@@ -64,6 +81,8 @@ export function createEntry(
|
|
|
64
81
|
stale: false,
|
|
65
82
|
lock: new WindowLock(),
|
|
66
83
|
reading: null,
|
|
84
|
+
generation: 0,
|
|
85
|
+
applied: 0,
|
|
67
86
|
};
|
|
68
87
|
}
|
|
69
88
|
|
|
@@ -82,18 +101,21 @@ export async function fillWindow(
|
|
|
82
101
|
// Read before `startRead` clears it: a second caller arriving during the read joins it and is
|
|
83
102
|
// not the one that forced it, which is what keeps one forced read from becoming N.
|
|
84
103
|
const forced = entry.stale;
|
|
85
|
-
const
|
|
104
|
+
const pending = forced || entry.reading === null ? startRead(entry) : entry.reading;
|
|
105
|
+
const result = await pending.result;
|
|
86
106
|
return await entry.lock.run(async () => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
// Two rules, and neither can stand in for the other. Against another READ it is identity —
|
|
108
|
+
// the same check `startRead` makes on `entry.reading` one function down, and the one
|
|
109
|
+
// `packages/cache/src/single-flight.ts` makes for the same reason — because an lsn cannot
|
|
110
|
+
// order two reads at all: a definition with no lsn provider answers `''` for both, and
|
|
111
|
+
// `'' >= ''` let the older one overwrite the gap repair the newer one had just landed, with
|
|
112
|
+
// `stale` already cleared by its issue and therefore nothing left to re-read. Against a
|
|
113
|
+
// CHANGE it is still the lsn, because a fanout moved `entry.lsn` forwards while this read was
|
|
114
|
+
// in flight and rewinding to what the read saw hands that subscriber rows the fanout has
|
|
115
|
+
// moved past — except for a forced read, which was issued *because* what is under it is
|
|
116
|
+
// wrong.
|
|
117
|
+
if (isNewestRead(entry, pending) && (forced || result.lsn >= entry.lsn)) {
|
|
118
|
+
applyRead(entry, pending, result);
|
|
97
119
|
}
|
|
98
120
|
return { rows: entry.rows, lsn: entry.lsn };
|
|
99
121
|
});
|
|
@@ -105,22 +127,36 @@ export async function fillWindow(
|
|
|
105
127
|
* that repairs a stale window mid-fanout is spelled here rather than deadlocking on the other.
|
|
106
128
|
*/
|
|
107
129
|
export async function refillWindowInLane(entry: QueryEntry): Promise<void> {
|
|
108
|
-
const
|
|
130
|
+
const pending = startRead(entry);
|
|
131
|
+
const result = await pending.result;
|
|
132
|
+
// Same identity rule as `fillWindow`: a read issued before this one may still be in flight, and
|
|
133
|
+
// whichever was issued LAST is the one the window keeps.
|
|
134
|
+
if (isNewestRead(entry, pending)) applyRead(entry, pending, result);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Is this the newest read to have landed? An older one's rows are behind the window, not on it. */
|
|
138
|
+
function isNewestRead(entry: QueryEntry, pending: PendingRead): boolean {
|
|
139
|
+
return pending.generation > entry.applied;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function applyRead(entry: QueryEntry, pending: PendingRead, result: SnapshotResult): void {
|
|
143
|
+
entry.applied = pending.generation;
|
|
109
144
|
entry.rows = result.rows;
|
|
110
145
|
if (result.lsn > entry.lsn) entry.lsn = result.lsn;
|
|
111
146
|
}
|
|
112
147
|
|
|
113
148
|
/** Publishes the in-flight read, and clears it as it settles — the share is per read, not a cache. */
|
|
114
|
-
function startRead(entry: QueryEntry):
|
|
149
|
+
function startRead(entry: QueryEntry): PendingRead {
|
|
115
150
|
// Cleared here rather than when the read lands: the read about to be issued is the one that
|
|
116
151
|
// answers the staleness, so a second caller must join it instead of forcing another.
|
|
117
152
|
entry.stale = false;
|
|
118
|
-
|
|
153
|
+
entry.generation += 1;
|
|
154
|
+
const reading: PendingRead = { generation: entry.generation, result: readSnapshot(entry) };
|
|
119
155
|
entry.reading = reading;
|
|
120
156
|
const done = (): void => {
|
|
121
157
|
if (entry.reading === reading) entry.reading = null;
|
|
122
158
|
};
|
|
123
|
-
void reading.then(done, done);
|
|
159
|
+
void reading.result.then(done, done);
|
|
124
160
|
return reading;
|
|
125
161
|
}
|
|
126
162
|
|
package/src/socket.ts
CHANGED
|
@@ -126,7 +126,13 @@ export class SyncSocket {
|
|
|
126
126
|
readonly frameBudget: AcceptBudget;
|
|
127
127
|
|
|
128
128
|
actor: Actor | null;
|
|
129
|
-
|
|
129
|
+
/**
|
|
130
|
+
* MONOTONIC milliseconds, not an instant — the idle sweep measures a duration, and a duration
|
|
131
|
+
* read off the wall clock is decided by whatever NTP last wrote. A step forward evicts sockets
|
|
132
|
+
* that are talking; a step backward makes `idleFor` negative and spares sockets that are dead.
|
|
133
|
+
* Named for the units so nobody hands it to `new Date()`; `openedAt` is the wall-clock one.
|
|
134
|
+
*/
|
|
135
|
+
lastSeenMonotonicMs: number;
|
|
130
136
|
droppedFrames = 0;
|
|
131
137
|
sentFrames = 0;
|
|
132
138
|
|
|
@@ -150,8 +156,10 @@ export class SyncSocket {
|
|
|
150
156
|
burst: options.frameBurst ?? DEFAULT_FRAME_BURST,
|
|
151
157
|
clock: this.#clock,
|
|
152
158
|
});
|
|
159
|
+
// Two clocks on purpose: `openedAt` is an instant a human reads, `lastSeenMonotonicMs` is the
|
|
160
|
+
// start of a duration only this process compares.
|
|
153
161
|
this.openedAt = this.#clock.now().getTime();
|
|
154
|
-
this.
|
|
162
|
+
this.lastSeenMonotonicMs = this.#clock.monotonic();
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
get actorId(): string | null {
|
|
@@ -206,11 +214,12 @@ export class SyncSocket {
|
|
|
206
214
|
}
|
|
207
215
|
|
|
208
216
|
touch(): void {
|
|
209
|
-
this.
|
|
217
|
+
this.lastSeenMonotonicMs = this.#clock.monotonic();
|
|
210
218
|
}
|
|
211
219
|
|
|
212
|
-
|
|
213
|
-
|
|
220
|
+
/** `nowMonotonicMs` comes from the SAME clock — `Clock.monotonic()`, never `now().getTime()`. */
|
|
221
|
+
idleFor(nowMonotonicMs: number): number {
|
|
222
|
+
return nowMonotonicMs - this.lastSeenMonotonicMs;
|
|
214
223
|
}
|
|
215
224
|
|
|
216
225
|
close(code: number = CLOSE.normal, reason = ''): void {
|
|
@@ -220,9 +229,28 @@ export class SyncSocket {
|
|
|
220
229
|
}
|
|
221
230
|
}
|
|
222
231
|
|
|
232
|
+
/**
|
|
233
|
+
* How long a socket may route no frame before `sync-node` evicts it. It is an APPLICATION
|
|
234
|
+
* inactivity budget and not Bun's transport one: Bun's `idleTimeout` is renewed by its own
|
|
235
|
+
* ping/pong, so a client whose TCP stack still answers pings while its frame loop is wedged holds
|
|
236
|
+
* its grant, its subscriptions and its topic membership forever. A beating client sends a `hello`
|
|
237
|
+
* every `DEFAULT_HEARTBEAT_MS` (15s), so this is eight missed beats.
|
|
238
|
+
*/
|
|
239
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* How often to ask. A quarter of the budget, floored at a second: a socket is evicted within 25%
|
|
243
|
+
* of its window of going quiet, and a node holding 50,000 of them pays one pass over the table
|
|
244
|
+
* four times per window rather than once a second. Derived rather than configured — a second knob
|
|
245
|
+
* is a second number that can disagree with the one it is a fraction of.
|
|
246
|
+
*/
|
|
247
|
+
export function idleSweepPeriodMs(idleTimeoutMs: number): number {
|
|
248
|
+
return Math.max(1_000, Math.floor(idleTimeoutMs / 4));
|
|
249
|
+
}
|
|
250
|
+
|
|
223
251
|
export interface SocketRegistryOptions {
|
|
224
252
|
readonly clock?: Clock;
|
|
225
|
-
/** Bun
|
|
253
|
+
/** Bun's own `idleTimeout` is renewed by its ping/pong; this budget counts routed FRAMES. */
|
|
226
254
|
readonly idleTimeoutMs?: number;
|
|
227
255
|
}
|
|
228
256
|
|
|
@@ -244,7 +272,7 @@ export class SocketRegistry {
|
|
|
244
272
|
|
|
245
273
|
constructor(options: SocketRegistryOptions = {}) {
|
|
246
274
|
this.#clock = options.clock ?? systemClock;
|
|
247
|
-
this.#idleTimeoutMs = options.idleTimeoutMs ??
|
|
275
|
+
this.#idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
248
276
|
}
|
|
249
277
|
|
|
250
278
|
/**
|
|
@@ -310,19 +338,26 @@ export class SocketRegistry {
|
|
|
310
338
|
return this.#sockets.size;
|
|
311
339
|
}
|
|
312
340
|
|
|
313
|
-
/**
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
341
|
+
/** The budget `idle()` answers against, so a caller can size its own sweep from one number. */
|
|
342
|
+
get idleTimeoutMs(): number {
|
|
343
|
+
return this.#idleTimeoutMs;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Everything past the idle budget. A QUERY, and deliberately not an eviction: this table is three
|
|
348
|
+
* of the five things a socket holds, and the other two — its live subscriptions and its presence
|
|
349
|
+
* membership on the SHARED set — are only reachable from `sync-node`'s `teardown`. A sweep that
|
|
350
|
+
* closed and `remove`d here left a member every other node renders until its TTL and a
|
|
351
|
+
* `QueryEntry` whose `subscribers` map never empties. `sync-node` is the one caller and it
|
|
352
|
+
* releases each one the way the close callback does.
|
|
353
|
+
*/
|
|
354
|
+
idle(): SyncSocket[] {
|
|
355
|
+
// Monotonic, because this is the one comparison an operator's clock could otherwise decide:
|
|
356
|
+
// `sync-node` hands this registry and every socket it builds the same `Clock`.
|
|
357
|
+
const now = this.#clock.monotonic();
|
|
358
|
+
return [...this.#sockets.values()].filter(
|
|
359
|
+
(socket) => socket.idleFor(now) > this.#idleTimeoutMs,
|
|
360
|
+
);
|
|
326
361
|
}
|
|
327
362
|
|
|
328
363
|
/**
|
package/src/sync-node.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { CHANGE_SUBJECT_PREFIX, parseEnvelope, SeqGapDetector } from './replicat
|
|
|
14
14
|
import {
|
|
15
15
|
CLOSE,
|
|
16
16
|
DEFAULT_MAX_BUFFERED_BYTES,
|
|
17
|
+
idleSweepPeriodMs,
|
|
17
18
|
SocketRegistry,
|
|
18
19
|
SyncSocket,
|
|
19
20
|
type WsLike,
|
|
@@ -76,6 +77,12 @@ export interface SyncNodeOptions {
|
|
|
76
77
|
*/
|
|
77
78
|
readonly maxBufferedBytes?: number;
|
|
78
79
|
readonly maxDroppedFrames?: number;
|
|
80
|
+
/**
|
|
81
|
+
* How long a socket may route no frame before this node evicts it. Every ceiling on a socket
|
|
82
|
+
* `sync` builds has to be reachable from here, and this one was not: `SocketRegistry`'s default
|
|
83
|
+
* was only settable by constructing the registry yourself, and nothing swept it either way.
|
|
84
|
+
*/
|
|
85
|
+
readonly idleTimeoutMs?: number;
|
|
79
86
|
readonly onMutate?: MutationHandler;
|
|
80
87
|
/**
|
|
81
88
|
* Who is dialling. Injected for the same reason `onMutate` is: `sync` owns no business logic and
|
|
@@ -131,7 +138,11 @@ export interface SyncNode {
|
|
|
131
138
|
|
|
132
139
|
export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
133
140
|
const sockets =
|
|
134
|
-
options.sockets ??
|
|
141
|
+
options.sockets ??
|
|
142
|
+
new SocketRegistry({
|
|
143
|
+
...(options.clock ? { clock: options.clock } : {}),
|
|
144
|
+
...(options.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: options.idleTimeoutMs }),
|
|
145
|
+
});
|
|
135
146
|
const clock = options.clock ?? systemClock;
|
|
136
147
|
const accept = options.accept ?? new AcceptBudget({ perSecond: 500, burst: 2000, clock });
|
|
137
148
|
const maxConnections = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
@@ -143,6 +154,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
143
154
|
let changes: TransportSubscription | null = null;
|
|
144
155
|
let sweeping: ReturnType<typeof setInterval> | null = null;
|
|
145
156
|
let reauthing: ReturnType<typeof setInterval> | null = null;
|
|
157
|
+
let idling: ReturnType<typeof setInterval> | null = null;
|
|
146
158
|
|
|
147
159
|
/**
|
|
148
160
|
* Work nobody is waiting on — a presence leave from a synchronous close, a sweep on a timer, a
|
|
@@ -178,6 +190,8 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
178
190
|
sweeping = null;
|
|
179
191
|
if (reauthing !== null) clearInterval(reauthing);
|
|
180
192
|
reauthing = null;
|
|
193
|
+
if (idling !== null) clearInterval(idling);
|
|
194
|
+
idling = null;
|
|
181
195
|
gaps.forget();
|
|
182
196
|
};
|
|
183
197
|
|
|
@@ -200,6 +214,17 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
200
214
|
}
|
|
201
215
|
};
|
|
202
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The node's one eviction: close, then release everything the socket held. Every path that ends
|
|
219
|
+
* a socket without a `close` callback behind it — the drain, the idle sweep — goes through it,
|
|
220
|
+
* because dropping the socket from the table is three of `teardown`'s five steps and the two it
|
|
221
|
+
* misses are the ones another node can see.
|
|
222
|
+
*/
|
|
223
|
+
const evict = (socket: SyncSocket, code: number, reason: string): void => {
|
|
224
|
+
socket.close(code, reason);
|
|
225
|
+
teardown(socket);
|
|
226
|
+
};
|
|
227
|
+
|
|
203
228
|
/**
|
|
204
229
|
* One pass over the grants whose window has closed. This is the half R2 was missing: `reauthorize`
|
|
205
230
|
* and `onActorChange` were both written and neither had a caller, so a socket that was accepted
|
|
@@ -277,6 +302,14 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
277
302
|
);
|
|
278
303
|
sweeping.unref();
|
|
279
304
|
}
|
|
305
|
+
// The half-open connection Bun's own `idleTimeout` renews through its ping/pong: a client
|
|
306
|
+
// whose frame loop is wedged answers pings and keeps its grant, its subscriptions and its
|
|
307
|
+
// topic membership. `sweepIdle` was written for this and never called, so `touch()` and the
|
|
308
|
+
// 120s budget under it decided nothing.
|
|
309
|
+
idling = setInterval(() => {
|
|
310
|
+
for (const socket of sockets.idle()) evict(socket, CLOSE.idle, 'idle timeout');
|
|
311
|
+
}, idleSweepPeriodMs(sockets.idleTimeoutMs));
|
|
312
|
+
idling.unref();
|
|
280
313
|
if (options.authenticate) {
|
|
281
314
|
reauthing = setInterval(
|
|
282
315
|
() => detach(reauthenticate(), 'sync.reauthenticate'),
|
|
@@ -419,11 +452,13 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
419
452
|
}
|
|
420
453
|
const graceMs = drainOptions.graceMs ?? 5_000;
|
|
421
454
|
if (graceMs > 0) await new Promise((resolve) => setTimeout(resolve, graceMs));
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
455
|
+
// Through `evict`, never `sockets.remove` + `grants.delete`: those are three of `teardown`'s
|
|
456
|
+
// five steps, and the two they skip are the ones the rest of the fleet can see. A drained
|
|
457
|
+
// socket that never left its presence set is a member every other node renders for a full
|
|
458
|
+
// TTL — during a rolling restart, beside the same client's reconnection under a new id —
|
|
459
|
+
// and its live subscriptions stay in the registry, so `entry.subscribers` never empties and
|
|
460
|
+
// the matcher, the shared window and the retained ring are pinned for the process's life.
|
|
461
|
+
for (const socket of [...sockets.all()]) evict(socket, CLOSE.goingAway, 'drain');
|
|
427
462
|
// Released once the sockets are gone rather than at the top: a client is entitled to its
|
|
428
463
|
// patches for the whole grace window, and it is entitled to them *before* the hub the
|
|
429
464
|
// fanout writes through is closed.
|