@ultimat3/realtime 30.9.0 → 31.1.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 +72 -82
- package/README.md +36 -40
- package/package.json +8 -6
- package/src/adapter.ts +14 -0
- package/src/apply-patches.ts +8 -5
- package/src/boot.ts +15 -2
- package/src/changefeed.ts +11 -4
- package/src/channel-decl.ts +29 -1
- package/src/channel-publish.ts +17 -19
- package/src/channel-ref.ts +22 -2
- package/src/client-channels.ts +11 -5
- package/src/client-contract.ts +2 -2
- package/src/cursor.ts +5 -5
- package/src/device-agent.ts +2 -2
- package/src/device-errors.ts +1 -1
- package/src/device-socket.ts +1 -1
- package/src/error-titles-server.ts +40 -0
- package/src/error-titles.ts +12 -21
- package/src/errors.ts +43 -10
- package/src/idb-types.ts +6 -0
- package/src/index.ts +5 -27
- package/src/json.ts +7 -3
- package/src/live-contract.ts +4 -4
- package/src/live-definition.ts +9 -6
- package/src/live-deliver.ts +40 -2
- package/src/live-entries.ts +73 -0
- package/src/live-fanout.ts +25 -9
- package/src/live-patch-encode.ts +30 -0
- package/src/live-query-options.ts +11 -0
- package/src/live-query.ts +26 -11
- package/src/live-replicator.ts +24 -21
- package/src/local-store-idb.ts +66 -6
- package/src/matcher-bridge.ts +15 -15
- package/src/nats-transport.ts +5 -5
- package/src/offline-queue-store.ts +69 -0
- package/src/offline-queue.ts +91 -91
- package/src/page-errors.ts +15 -0
- package/src/page-outbox.ts +18 -3
- package/src/pg-connection.ts +9 -1
- package/src/pg-preflight.ts +21 -5
- package/src/pg-replication-dial.ts +95 -0
- package/src/pg-replication-liveness.ts +84 -0
- package/src/pg-replication-position.ts +42 -0
- package/src/pg-replication-row.ts +6 -6
- package/src/pg-replication-stats.ts +28 -0
- package/src/pg-replication.ts +106 -125
- package/src/pg-slot-release.ts +40 -0
- package/src/policy-gate.ts +2 -2
- package/src/query-window.ts +4 -4
- package/src/record-publisher.ts +2 -2
- package/src/record-store.ts +7 -5
- package/src/replication-errors.ts +40 -2
- package/src/replication-resync.ts +38 -0
- package/src/replicator-deadline.ts +33 -0
- package/src/replicator-reconnect.ts +85 -0
- package/src/replicator-resync.ts +30 -0
- package/src/replicator-row.ts +5 -5
- package/src/replicator-subject.ts +28 -0
- package/src/replicator.ts +79 -69
- package/src/server.ts +8 -6
- package/src/socket-engine.ts +2 -2
- package/src/subscriber-gate.ts +14 -10
- package/src/sync-bus-handlers.ts +20 -4
- package/src/sync-protocol.ts +3 -3
- package/src/thundering-herd.ts +17 -24
- package/src/transport-env.ts +20 -48
- package/src/window-lock.ts +15 -3
package/src/server.ts
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
// live-query registry it fans out through. Split from `index.ts` because `nats` require()s
|
|
3
3
|
// `stream/web` and the WAL decoder is a Postgres client — one barrel carrying both made the browser
|
|
4
4
|
// island realtime promises unbuildable. Every name here has exactly one home; the shared
|
|
5
|
-
// vocabulary (the wire, the errors, `
|
|
5
|
+
// vocabulary (the wire, the errors, `LiveRow`, the backoff) stays on `@ultimat3/realtime`.
|
|
6
|
+
|
|
7
|
+
// The server-only codes' titles (`sideEffects`): a process that loads this barrel titles them,
|
|
8
|
+
// whichever class it constructs first.
|
|
9
|
+
import './error-titles-server';
|
|
6
10
|
|
|
7
11
|
export { type AdvisoryLock, type MemoryAdvisoryLock, memoryAdvisoryLock } from './advisory-lock';
|
|
8
12
|
// ---- the retained change window one node fans out from ------------------------------------------
|
|
@@ -45,11 +49,8 @@ export {
|
|
|
45
49
|
} from './channel';
|
|
46
50
|
export { type ChannelDescription, describeChannels } from './channel-describe';
|
|
47
51
|
// What a job or an action publishes an event with: the process's own bus, no hub needed.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
resetChannelTransport,
|
|
51
|
-
setChannelTransport,
|
|
52
|
-
} from './channel-publish';
|
|
52
|
+
// Its installer is the boot's, on `./adapter` (`adapter.ts`), never here.
|
|
53
|
+
export { publishChannelEvent } from './channel-publish';
|
|
53
54
|
export {
|
|
54
55
|
type AgentDial,
|
|
55
56
|
type AgentSocket,
|
|
@@ -112,6 +113,7 @@ export type {
|
|
|
112
113
|
export { type LiveDefinitionOptions, liveQueryDefinition } from './live-definition';
|
|
113
114
|
export {
|
|
114
115
|
DEFAULT_MAX_ENTRIES,
|
|
116
|
+
DEFAULT_MAX_LANE_DEPTH,
|
|
115
117
|
LiveQueryRegistry,
|
|
116
118
|
type LiveQueryRegistryOptions,
|
|
117
119
|
} from './live-query';
|
package/src/socket-engine.ts
CHANGED
|
@@ -12,9 +12,9 @@ import type { AttachedPort, PortLike, PortMessage } from './socket-port';
|
|
|
12
12
|
import { PortRouter } from './socket-routes';
|
|
13
13
|
import { decode, encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
14
14
|
import {
|
|
15
|
-
type BackoffPolicy,
|
|
16
15
|
browserBackoff,
|
|
17
16
|
policyDelay,
|
|
17
|
+
type ReconnectBackoff,
|
|
18
18
|
type Rng,
|
|
19
19
|
type Scheduler,
|
|
20
20
|
timeoutScheduler,
|
|
@@ -28,7 +28,7 @@ export interface SocketEngineOptions {
|
|
|
28
28
|
readonly dial: (target: SyncTarget) => ClientSocket;
|
|
29
29
|
readonly scheduler?: Scheduler;
|
|
30
30
|
readonly clock?: Clock;
|
|
31
|
-
readonly backoff?:
|
|
31
|
+
readonly backoff?: ReconnectBackoff;
|
|
32
32
|
readonly rng?: Rng;
|
|
33
33
|
/** The engine's own beat on the real socket, and the unit a silent port is reaped in. */
|
|
34
34
|
readonly heartbeatMs?: number;
|
package/src/subscriber-gate.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Actor } from '@ultimat3/core';
|
|
7
7
|
import { isPolicyDenial } from './errors';
|
|
8
|
-
import type { JsonValue,
|
|
8
|
+
import type { JsonValue, LiveRow, RowPatch } from './json';
|
|
9
9
|
import type { LiveQueryDefinition } from './live-contract';
|
|
10
10
|
import { narrowRow, projectionOf } from './matcher-bridge';
|
|
11
11
|
|
|
@@ -52,7 +52,7 @@ export interface GateTarget {
|
|
|
52
52
|
readonly qid: string;
|
|
53
53
|
readonly input: JsonValue;
|
|
54
54
|
readonly definition: LiveQueryDefinition;
|
|
55
|
-
readonly rows: readonly
|
|
55
|
+
readonly rows: readonly LiveRow[];
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
@@ -62,12 +62,12 @@ export interface GateTarget {
|
|
|
62
62
|
* when a caller passes none.
|
|
63
63
|
*/
|
|
64
64
|
export interface WindowIndex {
|
|
65
|
-
readonly byId: ReadonlyMap<string,
|
|
65
|
+
readonly byId: ReadonlyMap<string, LiveRow>;
|
|
66
66
|
readonly position: ReadonlyMap<string, number>;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
export function windowIndex(rows: readonly
|
|
70
|
-
const byId = new Map<string,
|
|
69
|
+
export function windowIndex(rows: readonly LiveRow[]): WindowIndex {
|
|
70
|
+
const byId = new Map<string, LiveRow>();
|
|
71
71
|
const position = new Map<string, number>();
|
|
72
72
|
rows.forEach((row, at) => {
|
|
73
73
|
byId.set(row.id, row);
|
|
@@ -144,8 +144,12 @@ export class SubscriberGate {
|
|
|
144
144
|
* managed to admit: a short result set is indistinguishable from a correct one, and handing it
|
|
145
145
|
* over is the read silently losing rows.
|
|
146
146
|
*/
|
|
147
|
-
async filterRows(
|
|
148
|
-
|
|
147
|
+
async filterRows(
|
|
148
|
+
target: GateTarget,
|
|
149
|
+
who: Subscriber,
|
|
150
|
+
rows: readonly LiveRow[],
|
|
151
|
+
): Promise<LiveRow[]> {
|
|
152
|
+
const out: LiveRow[] = [];
|
|
149
153
|
for (const row of rows) {
|
|
150
154
|
if (await this.#visible(target, who, row, 'snapshot')) out.push(row);
|
|
151
155
|
else this.#denied(target.qid, who, row.id);
|
|
@@ -219,7 +223,7 @@ export class SubscriberGate {
|
|
|
219
223
|
// failed either: the shared window *is* the result set, so a row it does not hold is a row this
|
|
220
224
|
// subscriber is not entitled to keep, and one that holds it is told so.
|
|
221
225
|
if (full === undefined) return holds ? withdrawn(patch) : null;
|
|
222
|
-
const row:
|
|
226
|
+
const row: LiveRow = { ...full, ...patch.row, id: patch.id };
|
|
223
227
|
if (await this.#visible(target, who, row, 'patch')) {
|
|
224
228
|
// An update carries the changed columns only. A subscriber who was never sent this row —
|
|
225
229
|
// denied at its snapshot, admitted by this change — would fold them onto nothing, and its
|
|
@@ -247,7 +251,7 @@ export class SubscriberGate {
|
|
|
247
251
|
async #visible(
|
|
248
252
|
target: GateTarget,
|
|
249
253
|
who: Subscriber,
|
|
250
|
-
row:
|
|
254
|
+
row: LiveRow,
|
|
251
255
|
stage: GateStage,
|
|
252
256
|
): Promise<boolean> {
|
|
253
257
|
try {
|
|
@@ -302,7 +306,7 @@ const actorIdOf = (who: Subscriber): string | null => (who.actor === null ? null
|
|
|
302
306
|
function rebase(
|
|
303
307
|
patch: RowPatch,
|
|
304
308
|
holding: Holding,
|
|
305
|
-
rows: readonly
|
|
309
|
+
rows: readonly LiveRow[],
|
|
306
310
|
index: WindowIndex,
|
|
307
311
|
): RowPatch {
|
|
308
312
|
if (patch.index === undefined) return patch;
|
package/src/sync-bus-handlers.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { ChannelHub } from './channel';
|
|
|
8
8
|
import { detach } from './detach';
|
|
9
9
|
import type { LiveQueryRegistry } from './live-query';
|
|
10
10
|
import { RealtimeError } from './realtime-error';
|
|
11
|
+
import { parseResync } from './replication-resync';
|
|
11
12
|
import {
|
|
12
13
|
type ChangeEnvelope,
|
|
13
14
|
type EnvelopeSource,
|
|
@@ -83,6 +84,16 @@ export class ProducerKinds {
|
|
|
83
84
|
/** The handler a node subscribes to `CHANGE_SUBJECT_ALL` with. */
|
|
84
85
|
export function changeHandler(bus: SyncBus): (payload: string) => void {
|
|
85
86
|
return (payload) => {
|
|
87
|
+
// The replicator's own word that the stream has a hole no sequence shows (its slot was
|
|
88
|
+
// recreated): answered as a reconnect is, because it is the same fact — changes never seen.
|
|
89
|
+
const resync = parseResync(payload);
|
|
90
|
+
if (resync !== null) {
|
|
91
|
+
resyncAll(bus, 'live.replication_resync', {
|
|
92
|
+
reason: resync.reason,
|
|
93
|
+
producer: resync.producer,
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
86
97
|
const envelope = parseEnvelope(payload);
|
|
87
98
|
if (!envelope) return;
|
|
88
99
|
// Before the gap detector: a dropped producer's sequence is no stream this node follows.
|
|
@@ -127,9 +138,14 @@ export function changeHandler(bus: SyncBus): (payload: string) => void {
|
|
|
127
138
|
export function reconnectHandler(bus: SyncBus): () => void {
|
|
128
139
|
return () => {
|
|
129
140
|
bus.outage?.answered();
|
|
130
|
-
bus.
|
|
131
|
-
const marked = bus.registry.invalidate();
|
|
132
|
-
const channelGaps = bus.hub.invalidate();
|
|
133
|
-
logger.warn('live.bus_reconnected', { desynced: marked, channelGaps });
|
|
141
|
+
resyncAll(bus, 'live.bus_reconnected', {});
|
|
134
142
|
};
|
|
135
143
|
}
|
|
144
|
+
|
|
145
|
+
/** Every window and every records topic re-read, the producer history forgotten, said once. */
|
|
146
|
+
function resyncAll(bus: SyncBus, event: string, fields: Record<string, string>): void {
|
|
147
|
+
bus.gaps.forget();
|
|
148
|
+
const marked = bus.registry.invalidate();
|
|
149
|
+
const channelGaps = bus.hub.invalidate();
|
|
150
|
+
logger.warn(event, { ...fields, desynced: marked, channelGaps });
|
|
151
|
+
}
|
package/src/sync-protocol.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
isRow,
|
|
21
21
|
type JsonObject,
|
|
22
22
|
type JsonValue,
|
|
23
|
-
type
|
|
23
|
+
type LiveRow,
|
|
24
24
|
type RowPatch,
|
|
25
25
|
} from './json';
|
|
26
26
|
import { ProtocolVersionError } from './page-errors';
|
|
@@ -114,7 +114,7 @@ export interface SnapshotFrame {
|
|
|
114
114
|
readonly type: 'snapshot';
|
|
115
115
|
readonly v: number;
|
|
116
116
|
readonly sid: string;
|
|
117
|
-
readonly rows: readonly
|
|
117
|
+
readonly rows: readonly LiveRow[];
|
|
118
118
|
readonly cursor: LiveCursor;
|
|
119
119
|
/**
|
|
120
120
|
* The entity every row of this result set belongs to — the client's identity scope, so two
|
|
@@ -360,7 +360,7 @@ function writesOf(parsed: JsonObject, label: string): readonly string[] {
|
|
|
360
360
|
});
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
-
function row(value: unknown):
|
|
363
|
+
function row(value: unknown): LiveRow {
|
|
364
364
|
if (!isRow(value)) throw fail('row must be an object with a string "id"');
|
|
365
365
|
return value;
|
|
366
366
|
}
|
package/src/thundering-herd.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// 2. policyDelay() — the client's own jittered retry, for failures nobody scheduled
|
|
7
7
|
// 3. AcceptBudget — the receiving node's token bucket, so recovery sheds instead of collapsing
|
|
8
8
|
|
|
9
|
+
import type { RetryPolicy } from '@ultimat3/core';
|
|
9
10
|
import {
|
|
10
11
|
backoffDelay,
|
|
11
12
|
type Clock,
|
|
@@ -22,20 +23,20 @@ export type Rng = Random;
|
|
|
22
23
|
/** Core's, re-exported under this package's name — never a second copy of the same three modes. */
|
|
23
24
|
export type { JitterMode };
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
/**
|
|
27
|
+
* A redial's wait: core's `RetryPolicy` without its two bounds. A socket, a NATS link and the
|
|
28
|
+
* replicator redial until they are closed, so `attempts` and `timeBudgetMs` would be fields read by
|
|
29
|
+
* nothing — the shape is core's, projected, never restated (`BackoffPolicy` until 31.0.0).
|
|
30
|
+
*/
|
|
31
|
+
export type ReconnectBackoff = Pick<RetryPolicy, 'base' | 'max' | 'factor' | 'curve' | 'jitter'>;
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* `full` jitter by default: it is the only mode that actually decorrelates a herd. `equal` keeps a
|
|
34
35
|
* floor for latency-sensitive clients; `none` exists for tests and is never a production choice.
|
|
35
36
|
*/
|
|
36
|
-
export const defaultBackoff:
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
export const defaultBackoff: ReconnectBackoff = {
|
|
38
|
+
base: 500,
|
|
39
|
+
max: 30_000,
|
|
39
40
|
factor: 2,
|
|
40
41
|
jitter: 'full',
|
|
41
42
|
};
|
|
@@ -51,16 +52,16 @@ export const BROWSER_RECONNECT_MAX_MS = 4_000;
|
|
|
51
52
|
* window rather than at once, and the `AcceptBudget` sheds the excess before any query runs. A
|
|
52
53
|
* PLANNED restart never reaches this: the drain's `reconnect` frame assigns each socket its slot.
|
|
53
54
|
*/
|
|
54
|
-
export const browserBackoff:
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
export const browserBackoff: ReconnectBackoff = {
|
|
56
|
+
base: 500,
|
|
57
|
+
max: BROWSER_RECONNECT_MAX_MS,
|
|
57
58
|
factor: 2,
|
|
58
59
|
jitter: 'equal',
|
|
59
60
|
};
|
|
60
61
|
|
|
61
62
|
/**
|
|
62
|
-
* A {@link
|
|
63
|
-
* 1-BASED, core's count: the wait after the first failure is `attempt: 1` and is `
|
|
63
|
+
* A {@link ReconnectBackoff} through `@ultimat3/core`'s `backoffDelay` — the one curve. Attempt is
|
|
64
|
+
* 1-BASED, core's count: the wait after the first failure is `attempt: 1` and is `base`.
|
|
64
65
|
*
|
|
65
66
|
* Internal to this package and never re-exported from the barrel. Until 22.0.0 `.` exported a
|
|
66
67
|
* 0-based `backoffDelay` of its own that shifted by one before delegating, which made two counting
|
|
@@ -68,19 +69,11 @@ export const browserBackoff: BackoffPolicy = {
|
|
|
68
69
|
* catch-up retry did) waited twice as long as it meant to, with no error anywhere.
|
|
69
70
|
*/
|
|
70
71
|
export function policyDelay(
|
|
71
|
-
policy:
|
|
72
|
+
policy: ReconnectBackoff,
|
|
72
73
|
attempt: number,
|
|
73
74
|
rng: Rng = Math.random,
|
|
74
75
|
): number {
|
|
75
|
-
return backoffDelay({
|
|
76
|
-
attempt,
|
|
77
|
-
base: policy.baseMs,
|
|
78
|
-
max: policy.maxMs,
|
|
79
|
-
factor: policy.factor,
|
|
80
|
-
curve: 'exponential',
|
|
81
|
-
jitter: policy.jitter,
|
|
82
|
-
random: rng,
|
|
83
|
-
});
|
|
76
|
+
return backoffDelay({ ...policy, attempt, random: rng });
|
|
84
77
|
}
|
|
85
78
|
|
|
86
79
|
/**
|
package/src/transport-env.ts
CHANGED
|
@@ -1,30 +1,27 @@
|
|
|
1
|
-
// Single responsibility:
|
|
1
|
+
// Single responsibility: environment → fanout transport. The one place a boot
|
|
2
2
|
// decides whether this process fans changes out inside its own heap or over NATS, so `x dev`, a
|
|
3
3
|
// `sync` container and any custom host resolve it identically. The KV bucket and the presence TTL are decided here too:
|
|
4
4
|
// the bucket's whole-stream age limit and `PresenceRegistry`'s TTL are the same number seen from
|
|
5
5
|
// two sides, and a caller that had to pass each one separately could quietly set them apart.
|
|
6
6
|
|
|
7
|
-
import type { Clock
|
|
8
|
-
import {
|
|
7
|
+
import type { Clock } from '@ultimat3/core';
|
|
8
|
+
import { finiteOption } from '@ultimat3/core';
|
|
9
9
|
import type { Transport } from './fanout';
|
|
10
10
|
import { InProcessTransport } from './fanout';
|
|
11
11
|
import type { NatsConnect } from './nats-client';
|
|
12
12
|
import { assertBucket } from './nats-jetstream';
|
|
13
13
|
import { NatsTransport } from './nats-transport';
|
|
14
|
-
import type {
|
|
14
|
+
import type { ReconnectBackoff } from './thundering-herd';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* The keys read here, and nothing else. Named once so docs and tests cannot drift from the code.
|
|
18
|
-
* `NATS_URL` is the
|
|
19
|
-
*
|
|
18
|
+
* `NATS_URL` set is the bus; unset is the in-process one. Any other bus is `apps/<app>/runtime.ts`'s
|
|
19
|
+
* `transport`, which the boot takes INSTEAD of calling this.
|
|
20
20
|
*/
|
|
21
21
|
export const TRANSPORT_ENV_KEYS = ['NATS_URL', 'NATS_KV_BUCKET'] as const;
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
/** Named in every refusal, so the reader edits the file the decision lives in. */
|
|
27
|
-
const CONFIG_FILE = 'app.config.ts';
|
|
23
|
+
/** Where a bus that is neither NATS nor in-process is handed over. */
|
|
24
|
+
const RUNTIME_FILE = 'apps/<app>/runtime.ts';
|
|
28
25
|
|
|
29
26
|
/**
|
|
30
27
|
* One bucket per deployment, not per cluster: two apps sharing a nats-server would otherwise share
|
|
@@ -96,7 +93,7 @@ export interface SelectTransportOptions {
|
|
|
96
93
|
/** Default `BUS_CONNECT_WAIT_MS`. Read under `'sockets'` and `'feed'` only. */
|
|
97
94
|
readonly connectWithinMs?: number | undefined;
|
|
98
95
|
/** The dial's retry curve; the transport's own default when unset. */
|
|
99
|
-
readonly backoff?:
|
|
96
|
+
readonly backoff?: ReconnectBackoff | undefined;
|
|
100
97
|
/** Where the transport's background failures go; its own logger line when unset. */
|
|
101
98
|
readonly onError?: ((error: unknown, subject: string) => void) | undefined;
|
|
102
99
|
}
|
|
@@ -105,22 +102,19 @@ const nonEmpty = (value: string | undefined): string | undefined =>
|
|
|
105
102
|
value === undefined || value.trim().length === 0 ? undefined : value.trim();
|
|
106
103
|
|
|
107
104
|
/**
|
|
108
|
-
* The
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
105
|
+
* The environment decides the default bus: `NATS_URL` set (and not blank) is NATS, unset is the
|
|
106
|
+
* in-process bus. Until 31.0.0 `app.config.ts`'s `realtime.transport`/`urlEnv` decided and the env
|
|
107
|
+
* only had to agree — a closed union no app could add a bus to. A bus of the app's own is
|
|
108
|
+
* `transport` in `apps/<app>/runtime.ts`, and the boot never calls this for it.
|
|
112
109
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* one expected fanout across nodes; keeping every change in this heap instead is the same silent
|
|
116
|
-
* failure seen from the other side, so the two are refused rather than reconciled.
|
|
110
|
+
* The one silent failure left — a `sync` node on the in-process bus with its replicator in another
|
|
111
|
+
* process — is refused at boot by the caller, which knows the roles (`X_REALTIME_TOPOLOGY`).
|
|
117
112
|
*
|
|
118
113
|
* The bucket name is validated here rather than on first connect: a typo'd bucket is a boot that
|
|
119
114
|
* reports a healthy bus and then fails every presence write.
|
|
120
115
|
*/
|
|
121
116
|
export function selectTransport(
|
|
122
117
|
env: TransportEnvironment,
|
|
123
|
-
topology: RealtimeTopology,
|
|
124
118
|
options: SelectTransportOptions = {},
|
|
125
119
|
): TransportSelection {
|
|
126
120
|
const presenceTtlMs = finiteOption(
|
|
@@ -129,16 +123,16 @@ export function selectTransport(
|
|
|
129
123
|
options.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS,
|
|
130
124
|
);
|
|
131
125
|
const use = options.use ?? 'sockets';
|
|
126
|
+
const url = nonEmpty(env['NATS_URL']);
|
|
132
127
|
|
|
133
|
-
if (
|
|
134
|
-
refuseStrayBusUrl(env, topology);
|
|
128
|
+
if (url === undefined) {
|
|
135
129
|
const transport = new InProcessTransport(
|
|
136
130
|
options.clock === undefined ? {} : { clock: options.clock },
|
|
137
131
|
);
|
|
138
132
|
return {
|
|
139
133
|
transport,
|
|
140
134
|
mode: 'embedded',
|
|
141
|
-
detail: `in-process fanout — set
|
|
135
|
+
detail: `in-process fanout — set NATS_URL to reach the other nodes, or transport in ${RUNTIME_FILE} for another bus`,
|
|
142
136
|
bucket: null,
|
|
143
137
|
presenceTtlMs,
|
|
144
138
|
use,
|
|
@@ -147,16 +141,6 @@ export function selectTransport(
|
|
|
147
141
|
};
|
|
148
142
|
}
|
|
149
143
|
|
|
150
|
-
const urlEnv = topology.urlEnv ?? 'NATS_URL';
|
|
151
|
-
const url = nonEmpty(env[urlEnv]);
|
|
152
|
-
if (url === undefined) {
|
|
153
|
-
throw new ConfigInvalidError({
|
|
154
|
-
cause: `realtime.transport is 'nats' and realtime.urlEnv names ${urlEnv}, which is unset in this process's environment, so no node would be reachable`,
|
|
155
|
-
fix: `set ${urlEnv} to the nats-server url for every realtime role (web, sync, replicator), or set realtime: { transport: 'memory' } in ${CONFIG_FILE} for a single node`,
|
|
156
|
-
meta: { key: 'realtime.urlEnv', variable: urlEnv },
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
144
|
const bucket = nonEmpty(env['NATS_KV_BUCKET']) ?? DEFAULT_PRESENCE_BUCKET;
|
|
161
145
|
assertBucket(bucket);
|
|
162
146
|
const transport = new NatsTransport({
|
|
@@ -180,7 +164,7 @@ export function selectTransport(
|
|
|
180
164
|
return {
|
|
181
165
|
transport,
|
|
182
166
|
mode: 'external',
|
|
183
|
-
detail:
|
|
167
|
+
detail: 'NATS_URL',
|
|
184
168
|
bucket,
|
|
185
169
|
presenceTtlMs,
|
|
186
170
|
use,
|
|
@@ -192,21 +176,9 @@ export function selectTransport(
|
|
|
192
176
|
await transport.connect({
|
|
193
177
|
withinMs,
|
|
194
178
|
// The variable, never the url behind it. `-js` only where the bucket is asked for.
|
|
195
|
-
fix: `x doctor — then check
|
|
179
|
+
fix: `x doctor — then check NATS_URL names a reachable nats-server${use === 'sockets' ? ' started with -js (JetStream)' : ''} — this process ${NEEDS[use]} and cannot start without it; the web, worker and scheduler roles boot without one and connect in the background`,
|
|
196
180
|
});
|
|
197
181
|
},
|
|
198
182
|
state: () => (transport.connected ? 'up' : 'connecting'),
|
|
199
183
|
};
|
|
200
184
|
}
|
|
201
|
-
|
|
202
|
-
/** `'memory'` with a bus url in the environment: the conflict `selectTransport` refuses. */
|
|
203
|
-
function refuseStrayBusUrl(env: TransportEnvironment, topology: RealtimeTopology): void {
|
|
204
|
-
const names = topology.urlEnv === undefined ? ['NATS_URL'] : ['NATS_URL', topology.urlEnv];
|
|
205
|
-
const set = names.find((name) => nonEmpty(env[name]) !== undefined);
|
|
206
|
-
if (set === undefined) return;
|
|
207
|
-
throw new ConfigInvalidError({
|
|
208
|
-
cause: `${set} is set but realtime.transport is 'memory', so this process would fan out in its own heap and reach no other node`,
|
|
209
|
-
fix: `set realtime: { transport: 'nats', urlEnv: '${set}' } in ${CONFIG_FILE} to use the bus, or unset ${set} for a single node`,
|
|
210
|
-
meta: { key: 'realtime.transport', variable: set },
|
|
211
|
-
});
|
|
212
|
-
}
|
package/src/window-lock.ts
CHANGED
|
@@ -8,14 +8,26 @@
|
|
|
8
8
|
/** FIFO, one task at a time. A task that rejects hands its rejection to its own caller and no one else. */
|
|
9
9
|
export class WindowLock {
|
|
10
10
|
#tail: Promise<unknown> = Promise.resolve();
|
|
11
|
+
#depth = 0;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Tasks queued or running. What a caller bounds the lane by (`live-deliver.ts`): every task is
|
|
15
|
+
* a closure holding its change, so a lane behind one slow policy pass is memory nobody capped.
|
|
16
|
+
*/
|
|
17
|
+
get depth(): number {
|
|
18
|
+
return this.#depth;
|
|
19
|
+
}
|
|
11
20
|
|
|
12
21
|
run<T>(work: () => Promise<T>): Promise<T> {
|
|
22
|
+
this.#depth += 1;
|
|
13
23
|
const result = this.#tail.then(work);
|
|
14
24
|
// The lane chains on a settled shadow, never on `result`: one delivery that threw must not
|
|
15
25
|
// reject every delivery queued behind it, and an unwatched shadow must not look unhandled.
|
|
16
|
-
this.#tail = result.then(
|
|
26
|
+
this.#tail = result.then(this.#settled, this.#settled);
|
|
17
27
|
return result;
|
|
18
28
|
}
|
|
19
|
-
}
|
|
20
29
|
|
|
21
|
-
|
|
30
|
+
readonly #settled = (): void => {
|
|
31
|
+
this.#depth -= 1;
|
|
32
|
+
};
|
|
33
|
+
}
|