@ultimat3/realtime 24.0.0 → 25.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 +61 -63
- package/README.md +131 -46
- package/package.json +9 -6
- package/src/advisory-lock.ts +10 -5
- package/src/boot.ts +14 -9
- package/src/changefeed-env.ts +8 -8
- package/src/changefeed.ts +42 -18
- package/src/channel-authz.ts +7 -7
- package/src/channel-describe.ts +1 -1
- package/src/channel-logs.ts +16 -25
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +3 -3
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel.ts +8 -7
- package/src/client-channels.ts +2 -0
- package/src/client-frames.ts +9 -2
- package/src/client.ts +29 -2
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +47 -90
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +24 -6
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +12 -2
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +81 -24
- package/src/live-query-options.ts +6 -0
- package/src/live-query.ts +66 -144
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +26 -4
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +127 -15
- package/src/offline-queue.ts +5 -0
- package/src/page-errors.ts +8 -9
- package/src/page-outbox.ts +5 -5
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +9 -4
- package/src/pg-connection.ts +7 -1
- package/src/pg-replication.ts +6 -6
- package/src/pg-socket.ts +20 -3
- package/src/pg-values.ts +29 -14
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +2 -2
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +1 -1
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +3 -3
- package/src/replicator-envelope.ts +61 -11
- package/src/replicator-row.ts +1 -1
- package/src/replicator.ts +12 -23
- package/src/server.ts +36 -18
- package/src/socket-defaults.ts +20 -0
- package/src/socket.ts +14 -19
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +1 -1
- package/src/sync-node-bounds.ts +1 -1
- package/src/sync-node-contract.ts +16 -2
- package/src/sync-node.ts +115 -109
- package/src/sync-origin.ts +1 -1
- package/src/sync-protocol.ts +73 -13
- package/src/sync-upgrade.ts +39 -0
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +9 -16
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +5 -5
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/server.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// island realtime promises unbuildable. Every name here has exactly one home; the shared
|
|
5
5
|
// vocabulary (the wire, the errors, `Row`, the backoff) stays on `@ultimat3/realtime`.
|
|
6
6
|
|
|
7
|
-
export { type AdvisoryLock,
|
|
7
|
+
export { type AdvisoryLock, type MemoryAdvisoryLock, memoryAdvisoryLock } from './advisory-lock';
|
|
8
8
|
// ---- the retained change window one node fans out from ------------------------------------------
|
|
9
9
|
export {
|
|
10
10
|
type ChangeBufferOptions,
|
|
@@ -19,11 +19,13 @@ export {
|
|
|
19
19
|
type ChangeFeedStartOptions,
|
|
20
20
|
type ChangeOp,
|
|
21
21
|
formatLsn,
|
|
22
|
-
|
|
23
|
-
type
|
|
24
|
-
|
|
25
|
-
type
|
|
22
|
+
type MemoryChangeFeed,
|
|
23
|
+
type MemoryChangeFeedOptions,
|
|
24
|
+
memoryChangeFeed,
|
|
25
|
+
type PostgresChangeFeed,
|
|
26
|
+
type PostgresChangeFeedOptions,
|
|
26
27
|
parseLsn,
|
|
28
|
+
postgresChangeFeed,
|
|
27
29
|
} from './changefeed';
|
|
28
30
|
export {
|
|
29
31
|
type ChangeFeedSelection,
|
|
@@ -114,7 +116,11 @@ export {
|
|
|
114
116
|
export { decodeToken, encodeToken, NatsKvSet, type NatsKvSetOptions } from './nats-kv';
|
|
115
117
|
export { openNatsClient } from './nats-open';
|
|
116
118
|
export { NatsTransport, type NatsTransportOptions } from './nats-transport';
|
|
117
|
-
export {
|
|
119
|
+
export {
|
|
120
|
+
type PostgresAdvisoryLock,
|
|
121
|
+
type PostgresAdvisoryLockOptions,
|
|
122
|
+
postgresAdvisoryLock,
|
|
123
|
+
} from './pg-advisory-lock';
|
|
118
124
|
// ---- the postgres replication path ------------------------------------------------------------
|
|
119
125
|
export { entityRow } from './pg-entity-row';
|
|
120
126
|
export { paramsChannelTables } from './pg-identity-tables';
|
|
@@ -130,11 +136,10 @@ export { answersWeakAuth } from './pg-tls';
|
|
|
130
136
|
// `entityRow`'s: a caller naming either type has to be able to name what is inside one.
|
|
131
137
|
export { decodeValue, type PhysicalRow, type PhysicalValue } from './pg-values';
|
|
132
138
|
export type { PgStream } from './pg-wire';
|
|
133
|
-
export {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
type PgRelation,
|
|
139
|
+
export type {
|
|
140
|
+
PgColumn,
|
|
141
|
+
PgOutputMessage,
|
|
142
|
+
PgRelation,
|
|
138
143
|
} from './pgoutput';
|
|
139
144
|
export { authorizeWithPolicy, type GateOptions, visibleWithPolicy } from './policy-gate';
|
|
140
145
|
export {
|
|
@@ -147,20 +152,28 @@ export {
|
|
|
147
152
|
type PresenceRoster,
|
|
148
153
|
presenceFrame,
|
|
149
154
|
} from './presence';
|
|
155
|
+
export { ANONYMOUS_SOCKET_MULTIPLIER, DEFAULT_MAX_SOCKETS_PER_ACTOR } from './principal-sockets';
|
|
150
156
|
export {
|
|
151
|
-
createEntry,
|
|
152
157
|
DEFAULT_READ_DEADLINE_MS,
|
|
153
158
|
type EntryOptions,
|
|
154
159
|
fillWindow,
|
|
155
160
|
orgIdOf,
|
|
156
161
|
type PendingRead,
|
|
157
162
|
type QueryEntry,
|
|
163
|
+
queryEntry,
|
|
158
164
|
refillWindowInLane,
|
|
159
165
|
} from './query-window';
|
|
166
|
+
// ---- channel records with no replicator: an app's committed rows, published by the app ----------
|
|
167
|
+
export {
|
|
168
|
+
type PublishOptions,
|
|
169
|
+
type RecordPublisher,
|
|
170
|
+
type RecordPublisherOptions,
|
|
171
|
+
recordPublisher,
|
|
172
|
+
} from './record-publisher';
|
|
160
173
|
export {
|
|
161
174
|
CHANGE_SUBJECT_PREFIX,
|
|
175
|
+
changeFeedReplicator,
|
|
162
176
|
changeSubject,
|
|
163
|
-
createReplicator,
|
|
164
177
|
normalize,
|
|
165
178
|
type Replicator,
|
|
166
179
|
type ReplicatorOptions,
|
|
@@ -168,6 +181,8 @@ export {
|
|
|
168
181
|
} from './replicator';
|
|
169
182
|
export {
|
|
170
183
|
type ChangeEnvelope,
|
|
184
|
+
type EnvelopeSource,
|
|
185
|
+
encodeEnvelope,
|
|
171
186
|
parseChange,
|
|
172
187
|
parseEnvelope,
|
|
173
188
|
SeqGapDetector,
|
|
@@ -175,15 +190,17 @@ export {
|
|
|
175
190
|
export {
|
|
176
191
|
actorIdOf,
|
|
177
192
|
CLOSE,
|
|
178
|
-
DEFAULT_FRAME_BURST,
|
|
179
|
-
DEFAULT_MAX_BUFFERED_BYTES,
|
|
180
|
-
DEFAULT_MAX_FRAMES_PER_SECOND,
|
|
181
193
|
SocketRegistry,
|
|
182
194
|
type SocketRegistryOptions,
|
|
183
195
|
SyncSocket,
|
|
184
196
|
type SyncSocketOptions,
|
|
185
197
|
type WsLike,
|
|
186
198
|
} from './socket';
|
|
199
|
+
export {
|
|
200
|
+
DEFAULT_FRAME_BURST,
|
|
201
|
+
DEFAULT_MAX_BUFFERED_BYTES,
|
|
202
|
+
DEFAULT_MAX_FRAMES_PER_SECOND,
|
|
203
|
+
} from './socket-defaults';
|
|
187
204
|
export { DEFAULT_IDLE_TIMEOUT_MS, idleSweepPeriodMs } from './socket-idle';
|
|
188
205
|
export type {
|
|
189
206
|
GateFailed,
|
|
@@ -192,6 +209,7 @@ export type {
|
|
|
192
209
|
Subscriber,
|
|
193
210
|
SubscriberGateOptions,
|
|
194
211
|
} from './subscriber-gate';
|
|
212
|
+
export { DEFAULT_MAX_PER_ACTOR, DEFAULT_MAX_PER_SOCKET } from './subscription-book';
|
|
195
213
|
export {
|
|
196
214
|
GrantBook,
|
|
197
215
|
type GrantSweepDeps,
|
|
@@ -201,9 +219,9 @@ export {
|
|
|
201
219
|
sweepGrants,
|
|
202
220
|
} from './sync-auth';
|
|
203
221
|
export {
|
|
204
|
-
createFrameRouter,
|
|
205
222
|
type FrameRouter,
|
|
206
223
|
type FrameRouterOptions,
|
|
224
|
+
frameRouter,
|
|
207
225
|
} from './sync-frames';
|
|
208
226
|
export {
|
|
209
227
|
type ListenOptions,
|
|
@@ -211,13 +229,13 @@ export {
|
|
|
211
229
|
type SyncListener,
|
|
212
230
|
} from './sync-listen';
|
|
213
231
|
export {
|
|
214
|
-
createSyncNode,
|
|
215
232
|
DEFAULT_MAX_CONNECTIONS,
|
|
216
233
|
DEFAULT_MAX_FRAME_BYTES,
|
|
217
234
|
DEFAULT_REAUTH_INTERVAL_MS,
|
|
218
235
|
type SyncNode,
|
|
219
236
|
type SyncNodeOptions,
|
|
220
237
|
type SyncWs,
|
|
238
|
+
syncNode,
|
|
221
239
|
type UpgradeTarget,
|
|
222
240
|
type WsData,
|
|
223
241
|
} from './sync-node';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The default ceilings one server socket is built with. Split from `socket.ts` at its size
|
|
2
|
+
// ceiling: the socket owns behaviour, this owns the numbers it and `sync-node.ts` agree on.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What one socket may ask this node to do per second, and how much of it may arrive at once.
|
|
6
|
+
*
|
|
7
|
+
* The burst clears `DEFAULT_MAX_PER_SOCKET` (128) plus a `hello`, because that is exactly what a
|
|
8
|
+
* legitimate client sends on connect; the sustained rate is well under the ~155 frames/s measured
|
|
9
|
+
* to consume a node through the subscribe path's amplifiers.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_MAX_FRAMES_PER_SECOND = 64;
|
|
12
|
+
export const DEFAULT_FRAME_BURST = 256;
|
|
13
|
+
/**
|
|
14
|
+
* Queued-but-unwritten bytes on one server socket before `send` declines and marks the subscriber
|
|
15
|
+
* desynced. `sync-node.ts` hands the same number to Bun as `backpressureLimit` rather than spelling
|
|
16
|
+
* it again: they are one socket's one buffer, and a check the runtime's own limit fires before is a
|
|
17
|
+
* check that never runs. The client half (`client-mutations.ts`) is deliberately its own constant —
|
|
18
|
+
* it is browser code, and importing this file would pull the node's socket registry into the tab.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
package/src/socket.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
logger,
|
|
15
15
|
recordConnection,
|
|
16
16
|
systemClock,
|
|
17
|
-
|
|
17
|
+
uuidV7,
|
|
18
18
|
} from '@ultimat3/core';
|
|
19
19
|
import { GapRepairs } from './channel-gaps';
|
|
20
20
|
import type { ChannelRecordsFrame } from './channel-wire';
|
|
@@ -55,6 +55,11 @@ export interface SyncSocketOptions {
|
|
|
55
55
|
readonly clientBuildId: string;
|
|
56
56
|
readonly serverBuildId: string;
|
|
57
57
|
readonly actor?: Actor | null;
|
|
58
|
+
/**
|
|
59
|
+
* The caller's address, resolved ONCE at the upgrade (`UpgradeDeps.clientAddressOf`) — the
|
|
60
|
+
* only subject an anonymous reader has. `null` when the host cannot say.
|
|
61
|
+
*/
|
|
62
|
+
readonly clientAddress?: string | null;
|
|
58
63
|
readonly id?: string;
|
|
59
64
|
readonly clock?: Clock;
|
|
60
65
|
/** Frames are dropped rather than queued past this. See `desynced`. */
|
|
@@ -66,23 +71,11 @@ export interface SyncSocketOptions {
|
|
|
66
71
|
readonly frameBurst?: number;
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
* to consume a node through the subscribe path's amplifiers.
|
|
75
|
-
*/
|
|
76
|
-
export const DEFAULT_MAX_FRAMES_PER_SECOND = 64;
|
|
77
|
-
export const DEFAULT_FRAME_BURST = 256;
|
|
78
|
-
/**
|
|
79
|
-
* Queued-but-unwritten bytes on one server socket before `send` declines and marks the subscriber
|
|
80
|
-
* desynced. `sync-node.ts` hands the same number to Bun as `backpressureLimit` rather than spelling
|
|
81
|
-
* it again: they are one socket's one buffer, and a check the runtime's own limit fires before is a
|
|
82
|
-
* check that never runs. The client half (`client-mutations.ts`) is deliberately its own constant —
|
|
83
|
-
* it is browser code, and importing this file would pull the node's socket registry into the tab.
|
|
84
|
-
*/
|
|
85
|
-
export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
74
|
+
import {
|
|
75
|
+
DEFAULT_FRAME_BURST,
|
|
76
|
+
DEFAULT_MAX_BUFFERED_BYTES,
|
|
77
|
+
DEFAULT_MAX_FRAMES_PER_SECOND,
|
|
78
|
+
} from './socket-defaults';
|
|
86
79
|
|
|
87
80
|
/**
|
|
88
81
|
* Channel frames this process dropped under backpressure. A dropped `records` frame is now marked
|
|
@@ -142,6 +135,7 @@ export class SyncSocket {
|
|
|
142
135
|
readonly frameBudget: AcceptBudget;
|
|
143
136
|
|
|
144
137
|
actor: Actor | null;
|
|
138
|
+
readonly clientAddress: string | null;
|
|
145
139
|
/**
|
|
146
140
|
* MONOTONIC milliseconds, not an instant — the idle sweep measures a duration, and a duration
|
|
147
141
|
* read off the wall clock is decided by whatever NTP last wrote. A step forward evicts sockets
|
|
@@ -163,10 +157,11 @@ export class SyncSocket {
|
|
|
163
157
|
constructor(options: SyncSocketOptions) {
|
|
164
158
|
this.#ws = options.ws;
|
|
165
159
|
this.#clock = options.clock ?? systemClock;
|
|
166
|
-
this.id = options.id ??
|
|
160
|
+
this.id = options.id ?? uuidV7();
|
|
167
161
|
this.#clientBuildId = options.clientBuildId;
|
|
168
162
|
this.serverBuildId = options.serverBuildId;
|
|
169
163
|
this.actor = options.actor ?? null;
|
|
164
|
+
this.clientAddress = options.clientAddress ?? null;
|
|
170
165
|
this.#maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
|
171
166
|
finiteOption('SyncSocket', 'maxBufferedBytes', this.#maxBufferedBytes);
|
|
172
167
|
this.#maxDroppedFrames = options.maxDroppedFrames ?? 32;
|
package/src/subscription-book.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Who holds which subscription, and the composite identity that makes that answerable. A `sid`
|
|
2
2
|
// is CLIENT data — unique only to the socket that chose it — so every lookup here takes the
|
|
3
|
-
// owner too, and the per-socket and per-
|
|
4
|
-
// the only thing that knows what exists. Every question it answers is indexed, never scanned.
|
|
3
|
+
// owner too, and the per-socket, per-tenant and per-actor caps are answered from this book because
|
|
4
|
+
// it is the only thing that knows what exists. Every question it answers is indexed, never scanned.
|
|
5
5
|
|
|
6
6
|
import type { Actor } from '@ultimat3/core';
|
|
7
|
-
import { finiteOption } from '@ultimat3/core';
|
|
7
|
+
import { addressNetwork, ConfigInvalidError, finiteOption, isAnonymous } from '@ultimat3/core';
|
|
8
8
|
import { SubscriptionIdTakenError, SubscriptionLimitError } from './errors';
|
|
9
9
|
import type { LiveSubscription } from './live-contract';
|
|
10
10
|
import type { SyncSocket } from './socket';
|
|
11
|
+
import { CappedCount } from './subscription-count';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* A slot taken synchronously at the top of `subscribe` and given back when it has either become a
|
|
@@ -32,11 +33,50 @@ export interface SubscriptionCaps {
|
|
|
32
33
|
readonly maxPerSocket?: number;
|
|
33
34
|
readonly maxPerTenant?: number;
|
|
34
35
|
readonly tenantOf?: (actor: Actor | null) => string | null;
|
|
36
|
+
/** Live subscriptions one actor may hold on this node, across all of its sockets. */
|
|
37
|
+
readonly maxPerActor?: number;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
/** Sockets may open this many live queries before `X_SUBSCRIPTION_LIMIT`. */
|
|
38
41
|
export const DEFAULT_MAX_PER_SOCKET = 128;
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* One actor's share of a node, across every socket it opens. A tenth of `DEFAULT_MAX_ENTRIES`
|
|
45
|
+
* (10,000), so no single actor reaches the node cap — before it, 79 sockets at 128 each filled the
|
|
46
|
+
* node and every other user's next subscribe was refused — and eight tabs at the per-socket 128
|
|
47
|
+
* still fit under it. `@ultimat3/core`'s `realtime.maxSubscriptionsPerActor` defaults to the same
|
|
48
|
+
* number; `runtime-realtime.test.ts` in `@ultimat3/cli` holds the two together.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_MAX_PER_ACTOR = 1_000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Who a socket counts against for the per-actor caps (subscriptions, and sockets at the upgrade):
|
|
54
|
+
* the actor, or — anonymous — the NETWORK of the client address resolved at the upgrade
|
|
55
|
+
* (`addressNetwork`: IPv4 exact, IPv6 by /64), so a visitor with no account is bounded too, and one
|
|
56
|
+
* IPv6 host cannot rotate through its /64 for a fresh budget per address. A socket that names
|
|
57
|
+
* neither shares ONE principal, `address:unknown`: fail closed, bounded together, not unbounded.
|
|
58
|
+
*/
|
|
59
|
+
export function principalOf(socket: Pick<SyncSocket, 'actor' | 'clientAddress'>): string {
|
|
60
|
+
return principalFor(socket.actor, socket.clientAddress);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The same key from its two parts — the upgrade has them before any socket exists. */
|
|
64
|
+
export function principalFor(actor: Actor | null, clientAddress: string | null): string {
|
|
65
|
+
if (actor !== null && !isAnonymous(actor)) return `actor:${actor.id}`;
|
|
66
|
+
return `address:${clientAddress === null ? 'unknown' : addressNetwork(clientAddress)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A per-actor ceiling is a COUNT of at least 1, refused as the config key of the same name is. */
|
|
70
|
+
function actorCeiling(value: number | undefined): number {
|
|
71
|
+
const max = value ?? DEFAULT_MAX_PER_ACTOR;
|
|
72
|
+
if (Number.isSafeInteger(max) && max >= 1) return max;
|
|
73
|
+
throw new ConfigInvalidError({
|
|
74
|
+
cause: `maxPerActor (realtime.maxSubscriptionsPerActor) must be a whole number of at least 1, not ${String(max)} — 0 refuses every subscribe, and a fraction or a NaN is a cap no count ever reaches`,
|
|
75
|
+
fix: `new LiveQueryRegistry({ maxPerActor: ${String(DEFAULT_MAX_PER_ACTOR)} }) # or realtime: { maxSubscriptionsPerActor: ${String(DEFAULT_MAX_PER_ACTOR)} } in app.config.ts`,
|
|
76
|
+
meta: { key: 'realtime.maxSubscriptionsPerActor', value: max },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
40
80
|
const SUBJECT = 'the subscription caps';
|
|
41
81
|
|
|
42
82
|
/**
|
|
@@ -60,21 +100,16 @@ export class SubscriptionBook {
|
|
|
60
100
|
readonly #bySid = new Map<string, LiveSubscription>();
|
|
61
101
|
/** socket id -> its sids. The drop list on close, the retry list on re-auth. */
|
|
62
102
|
readonly #bySocket = new Map<string, Set<string>>();
|
|
63
|
-
/** tenant -> live subscriptions held by its sockets. The per-tenant cap's whole answer. */
|
|
64
|
-
readonly #perTenant = new Map<string, number>();
|
|
65
|
-
/**
|
|
66
|
-
* The tenant each socket's subscriptions were counted under. Remembered rather than re-derived,
|
|
67
|
-
* because `socket.actor` is replaced by a re-auth: deriving it again at `delete` time would
|
|
68
|
-
* decrement a tenant that was never incremented and leave the old one counting forever.
|
|
69
|
-
*/
|
|
70
|
-
readonly #tenantOfSocket = new Map<string, string>();
|
|
71
103
|
/** sids a socket has claimed but not yet attached. Empty between subscribes, so it never grows. */
|
|
72
104
|
readonly #claimedBySocket = new Map<string, Set<string>>();
|
|
73
|
-
/** The
|
|
74
|
-
readonly #
|
|
105
|
+
/** The keys each in-flight claim was counted under, by `(socket, sid)` — read at attach. */
|
|
106
|
+
readonly #claimKeys = new Map<string, { tenant: string | null; actor: string | null }>();
|
|
107
|
+
/** The caps that span sockets: one per tenant (opt-in), one per actor (always). */
|
|
108
|
+
readonly #tenants: CappedCount;
|
|
109
|
+
readonly #actors: CappedCount;
|
|
75
110
|
readonly #caps: SubscriptionCaps;
|
|
76
111
|
/**
|
|
77
|
-
*
|
|
112
|
+
* Every cap screened here, once, rather than on the subscribe path — and `maxPerTenant` was not
|
|
78
113
|
* screened at all: it has no `??` default, which is the one shape `bun run finite-bounds` states
|
|
79
114
|
* in its own header that it cannot see. `count >= NaN` is false, so the only cap that spans the
|
|
80
115
|
* sockets of one tenant was off in silence; measured, a book built with `maxPerTenant: NaN`
|
|
@@ -82,7 +117,6 @@ export class SubscriptionBook {
|
|
|
82
117
|
* caller is saying "no per-tenant cap" rather than handing a number that is not one.
|
|
83
118
|
*/
|
|
84
119
|
readonly #maxPerSocket: number;
|
|
85
|
-
readonly #maxPerTenant: number | undefined;
|
|
86
120
|
|
|
87
121
|
constructor(caps: SubscriptionCaps = {}) {
|
|
88
122
|
this.#caps = caps;
|
|
@@ -91,10 +125,21 @@ export class SubscriptionBook {
|
|
|
91
125
|
'maxPerSocket',
|
|
92
126
|
caps.maxPerSocket ?? DEFAULT_MAX_PER_SOCKET,
|
|
93
127
|
);
|
|
94
|
-
this.#
|
|
95
|
-
caps.
|
|
96
|
-
|
|
97
|
-
|
|
128
|
+
this.#tenants = new CappedCount({
|
|
129
|
+
principalOf: (socket) => this.#caps.tenantOf?.(socket.actor) ?? null,
|
|
130
|
+
max:
|
|
131
|
+
caps.maxPerTenant === undefined
|
|
132
|
+
? undefined
|
|
133
|
+
: finiteOption(SUBJECT, 'maxPerTenant', caps.maxPerTenant),
|
|
134
|
+
scope: 'tenant',
|
|
135
|
+
knob: 'maxPerTenant',
|
|
136
|
+
});
|
|
137
|
+
this.#actors = new CappedCount({
|
|
138
|
+
principalOf,
|
|
139
|
+
max: actorCeiling(caps.maxPerActor),
|
|
140
|
+
scope: 'actor',
|
|
141
|
+
knob: 'realtime.maxSubscriptionsPerActor',
|
|
142
|
+
});
|
|
98
143
|
}
|
|
99
144
|
|
|
100
145
|
get(socketId: string, sid: string): LiveSubscription | undefined {
|
|
@@ -115,10 +160,8 @@ export class SubscriptionBook {
|
|
|
115
160
|
const sids = this.#bySocket.get(socketId);
|
|
116
161
|
if (sids) sids.add(subscription.sid);
|
|
117
162
|
else this.#bySocket.set(socketId, new Set([subscription.sid]));
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
this.#tenantOfSocket.set(socketId, tenant);
|
|
121
|
-
this.#perTenant.set(tenant, (this.#perTenant.get(tenant) ?? 0) + 1);
|
|
163
|
+
this.#tenants.added(subscription.socket);
|
|
164
|
+
this.#actors.added(subscription.socket);
|
|
122
165
|
}
|
|
123
166
|
|
|
124
167
|
delete(socketId: string, sid: string): void {
|
|
@@ -127,10 +170,8 @@ export class SubscriptionBook {
|
|
|
127
170
|
sids?.delete(sid);
|
|
128
171
|
const empty = sids === undefined || sids.size === 0;
|
|
129
172
|
if (empty) this.#bySocket.delete(socketId);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
this.#bump(tenant, -1);
|
|
133
|
-
if (empty) this.#tenantOfSocket.delete(socketId);
|
|
173
|
+
this.#tenants.deleted(socketId, empty);
|
|
174
|
+
this.#actors.deleted(socketId, empty);
|
|
134
175
|
}
|
|
135
176
|
|
|
136
177
|
/** A copy, because every caller mutates the book while walking it. */
|
|
@@ -152,29 +193,67 @@ export class SubscriptionBook {
|
|
|
152
193
|
|
|
153
194
|
/** Live subscriptions counted against one tenant. The metric the cap reads. */
|
|
154
195
|
tenantCount(tenant: string): number {
|
|
155
|
-
return this.#
|
|
196
|
+
return this.#tenants.count(tenant);
|
|
156
197
|
}
|
|
157
198
|
|
|
158
199
|
/**
|
|
159
|
-
* A re-auth moved this socket to another tenant
|
|
160
|
-
* this the
|
|
161
|
-
* refused for subscriptions it does not hold, another admitted past its cap.
|
|
200
|
+
* A re-auth moved this socket to another tenant — or another actor — so its subscriptions move
|
|
201
|
+
* with it. Without this the counts the caps read drift from the book for the rest of the process:
|
|
202
|
+
* one key refused for subscriptions it does not hold, another admitted past its cap.
|
|
162
203
|
*/
|
|
163
204
|
retenant(socket: SyncSocket): void {
|
|
164
205
|
const held = this.#bySocket.get(socket.id)?.size ?? 0;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (before === after) return;
|
|
168
|
-
if (before !== null) this.#bump(before, -held);
|
|
169
|
-
if (after === null) this.#tenantOfSocket.delete(socket.id);
|
|
170
|
-
else {
|
|
171
|
-
this.#tenantOfSocket.set(socket.id, after);
|
|
172
|
-
if (held > 0) this.#perTenant.set(after, (this.#perTenant.get(after) ?? 0) + held);
|
|
173
|
-
}
|
|
206
|
+
this.#tenants.reassign(socket, held);
|
|
207
|
+
this.#actors.reassign(socket, held);
|
|
174
208
|
}
|
|
175
209
|
|
|
176
210
|
/**
|
|
177
|
-
*
|
|
211
|
+
* About to attach `(socket, sid)`: if a re-auth moved the socket to another tenant or actor
|
|
212
|
+
* while the subscribe was in flight, the new key was never asked — ask it now, and refuse when
|
|
213
|
+
* it is full. Called before the attach writes anything, so a refusal leaves nothing behind.
|
|
214
|
+
*/
|
|
215
|
+
assertAttachable(socket: SyncSocket, sid: string): void {
|
|
216
|
+
const claimed = this.#claimKeys.get(subscriptionKey(socket.id, sid));
|
|
217
|
+
if (claimed === undefined) return;
|
|
218
|
+
this.#tenants.assertIfMoved(socket, claimed.tenant);
|
|
219
|
+
this.#actors.assertIfMoved(socket, claimed.actor);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* How many of this socket's subscriptions a re-auth must refuse so that neither its new tenant
|
|
224
|
+
* nor its new actor ends above its cap, and the refusal to send each — `null` when all fit.
|
|
225
|
+
* Asked BEFORE `retenant`, so the refused ones leave the key they were counted under.
|
|
226
|
+
*/
|
|
227
|
+
overflowOnRekey(
|
|
228
|
+
socket: SyncSocket,
|
|
229
|
+
): { readonly excess: number; readonly error: SubscriptionLimitError } | null {
|
|
230
|
+
const held = this.#bySocket.get(socket.id)?.size ?? 0;
|
|
231
|
+
const byActor = this.#actors.overflowOnRekey(socket, held);
|
|
232
|
+
const byTenant = this.#tenants.overflowOnRekey(socket, held);
|
|
233
|
+
if (byActor === 0 && byTenant === 0) return null;
|
|
234
|
+
return byActor >= byTenant
|
|
235
|
+
? { excess: byActor, error: this.#actors.refusalFor(socket) }
|
|
236
|
+
: { excess: byTenant, error: this.#tenants.refusalFor(socket) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The socket closed: forget the keys it was counted under, held or not. */
|
|
240
|
+
forgetSocket(socketId: string): void {
|
|
241
|
+
this.#tenants.forget(socketId);
|
|
242
|
+
this.#actors.forget(socketId);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Sockets either spanning cap still remembers — a leak shows here first. */
|
|
246
|
+
get trackedSockets(): number {
|
|
247
|
+
return this.#tenants.remembered + this.#actors.remembered;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Live subscriptions counted against one principal (`actor:<id>` / `address:<ip>`). */
|
|
251
|
+
actorCount(principal: string): number {
|
|
252
|
+
return this.#actors.count(principal);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Refuse a subscribe that would exceed a cap. Load shedding, not a crash: every scope throws
|
|
178
257
|
* `X_SUBSCRIPTION_LIMIT` naming which one refused, so the fix line points at one knob.
|
|
179
258
|
*
|
|
180
259
|
* Claims count, because the thing being bounded is work that starts before it is held: a
|
|
@@ -192,17 +271,8 @@ export class SubscriptionBook {
|
|
|
192
271
|
knob: 'maxPerSocket',
|
|
193
272
|
});
|
|
194
273
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (perTenant === undefined || tenant === null) return;
|
|
198
|
-
if (this.tenantCount(tenant) + (this.#claimedPerTenant.get(tenant) ?? 0) >= perTenant) {
|
|
199
|
-
throw new SubscriptionLimitError({
|
|
200
|
-
scope: 'tenant',
|
|
201
|
-
id: tenant,
|
|
202
|
-
limit: perTenant,
|
|
203
|
-
knob: 'maxPerTenant',
|
|
204
|
-
});
|
|
205
|
-
}
|
|
274
|
+
this.#tenants.assert(socket);
|
|
275
|
+
this.#actors.assert(socket);
|
|
206
276
|
}
|
|
207
277
|
|
|
208
278
|
/**
|
|
@@ -226,10 +296,10 @@ export class SubscriptionBook {
|
|
|
226
296
|
const claims = this.#claimedBySocket.get(socketId);
|
|
227
297
|
if (claims) claims.add(sid);
|
|
228
298
|
else this.#claimedBySocket.set(socketId, new Set([sid]));
|
|
229
|
-
const tenant = this.#
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
299
|
+
const tenant = this.#tenants.claim(socket);
|
|
300
|
+
const actor = this.#actors.claim(socket);
|
|
301
|
+
const claimKey = subscriptionKey(socketId, sid);
|
|
302
|
+
this.#claimKeys.set(claimKey, { tenant: tenant.principal, actor: actor.principal });
|
|
233
303
|
let released = false;
|
|
234
304
|
return {
|
|
235
305
|
release: (): void => {
|
|
@@ -238,22 +308,10 @@ export class SubscriptionBook {
|
|
|
238
308
|
const held = this.#claimedBySocket.get(socketId);
|
|
239
309
|
held?.delete(sid);
|
|
240
310
|
if (held !== undefined && held.size === 0) this.#claimedBySocket.delete(socketId);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
else this.#claimedPerTenant.delete(tenant);
|
|
311
|
+
this.#claimKeys.delete(claimKey);
|
|
312
|
+
tenant.release();
|
|
313
|
+
actor.release();
|
|
245
314
|
},
|
|
246
315
|
};
|
|
247
316
|
}
|
|
248
|
-
|
|
249
|
-
/** The tenant this socket's subscriptions are counted under: the remembered one, or the actor's. */
|
|
250
|
-
#tenantFor(socket: SyncSocket): string | null {
|
|
251
|
-
return this.#tenantOfSocket.get(socket.id) ?? this.#caps.tenantOf?.(socket.actor) ?? null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
#bump(tenant: string, by: number): void {
|
|
255
|
-
const next = (this.#perTenant.get(tenant) ?? 0) + by;
|
|
256
|
-
if (next > 0) this.#perTenant.set(tenant, next);
|
|
257
|
-
else this.#perTenant.delete(tenant);
|
|
258
|
-
}
|
|
259
317
|
}
|