@ultimat3/realtime 11.1.0 → 11.2.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 +29 -1
- package/README.md +6 -1
- package/package.json +3 -3
- package/src/errors.ts +24 -1
- package/src/index.ts +1 -0
- package/src/live-query.ts +14 -1
- package/src/query-window.ts +67 -2
- package/src/server.ts +2 -0
- package/src/thundering-herd.ts +27 -13
package/CLAUDE.md
CHANGED
|
@@ -397,6 +397,34 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
397
397
|
liveHookFor(liveFeed)` runs at import; `registerQueries()` stamps the name later, at boot.
|
|
398
398
|
- Type claims about the hook go in `type-pins.ts`, never in a `.test.ts` — `tsconfig.json` excludes
|
|
399
399
|
test files, so `tsc -b` never reads one and an assertion written there can never fail.
|
|
400
|
+
- **`backoffDelay` is `@ultimat3/core`'s, and `attempt + 1` is the whole of the seam** (`As of
|
|
401
|
+
2026-08-23`). A client counts its first reconnect as attempt **0** and core counts the first wait
|
|
402
|
+
as attempt **1**, so dropping the shift doubles every reconnect delay in the framework —
|
|
403
|
+
silently, and only under the load this file exists to survive.
|
|
404
|
+
`thundering-herd-core-parity.test.ts` pins it with the numbers (`[500, 1_000, 2_000, 4_000]`) and
|
|
405
|
+
with 17,280 comparisons across every jitter mode, base, cap, factor, attempt and roll.
|
|
406
|
+
`JitterMode` is core's type re-exported and `Rng` is core's `Random` — never a second spelling of
|
|
407
|
+
either. `drainPlan()` and `AcceptBudget` are NOT backoff and keep their own arithmetic: a slot in
|
|
408
|
+
a spread and a token bucket's refusal delay are different questions. `bun run flight-copies` is the
|
|
409
|
+
guard: a second curve-and-jitter function anywhere in `packages/*/src` is a build error, matched
|
|
410
|
+
on the literal shape rather than the name.
|
|
411
|
+
- **The SHARED window read carries a deadline, and it frees the SLOT — it cannot cancel the read**
|
|
412
|
+
(`As of 2026-08-23`). One `definition.snapshot` that never settled pinned `entry.reading` for the
|
|
413
|
+
life of the process, and every later cold subscriber joined a promise nothing would ever resolve:
|
|
414
|
+
one wedged read took every future subscriber of that query id with it. `startRead` now races the
|
|
415
|
+
read against `entry.schedule(…, entry.readDeadlineMs)`, default `DEFAULT_READ_DEADLINE_MS` (30s),
|
|
416
|
+
injectable per entry and through `new LiveQueryRegistry({ readDeadlineMs, schedule })`. Three
|
|
417
|
+
rules, each with its own test in `query-window.test.ts` and each proven by mutation:
|
|
418
|
+
- **A race, not just an eviction.** Freeing the slot alone leaves every caller ALREADY joined
|
|
419
|
+
awaiting a promise nothing settles; they are told instead, with `X_TIMEOUT`.
|
|
420
|
+
- **`X_TIMEOUT`, not a silent empty window.** A superseded read is discarded silently because a
|
|
421
|
+
strictly better window already exists to serve from; a timed-out read has none, and serving
|
|
422
|
+
`rows: []` as the whole result set is the exact fault `live-query.ts`'s `#read` refuses. The
|
|
423
|
+
rejecting-read path (`readSnapshot`'s catch) is the shape this matches, not the superseded one.
|
|
424
|
+
- **`stale` is put back**, for `readSnapshot`'s reason: a read that did not answer must not leave
|
|
425
|
+
the window looking authoritative.
|
|
426
|
+
There is **no "off" spelling** — a shared read with no deadline is the defect itself — and a `0`,
|
|
427
|
+
negative, `NaN` or `Infinity` value falls back to the default rather than to "now".
|
|
400
428
|
- The client owns its own reconnect: a closed socket arms **one** timer through the injected
|
|
401
429
|
`Scheduler`, and that timer calls `connect()`. `reconnectAt` is the render half and never the
|
|
402
430
|
mechanism — publishing it without arming anything is exactly the bug that shipped. Rules that
|
|
@@ -764,7 +792,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
764
792
|
| `nats-lib-client.ts` | the `nats` adapter — **the only file in the repo that imports `nats`** |
|
|
765
793
|
| `nats-jetstream.ts` / `nats-kv.ts` / `nats-transport.ts` | the JetStream KV bucket, presence over it, and the production `Transport` — all three written against the port |
|
|
766
794
|
| `nats-fake.ts` | an in-memory bus implementing the port — server semantics, not wire bytes; the only way to prove multi-node fanout under a sealed network |
|
|
767
|
-
| `cursor.ts` / `change-buffer.ts` / `thundering-herd.ts` | reconnect — the highest-risk area |
|
|
795
|
+
| `cursor.ts` / `change-buffer.ts` / `thundering-herd.ts` | reconnect — the highest-risk area. `thundering-herd.ts`'s backoff is core's, shifted 0-based to 1-based; the drain plan and the accept budget are its own |
|
|
768
796
|
| `identity-map.ts` | the client's single source of truth: one row value per `(scope, id)`, its holds and its batched change notification |
|
|
769
797
|
| `live-rows.ts` | one subscription's window over that map — its scope, its order, its retain/release, and `Registration` itself |
|
|
770
798
|
| `local-store.ts` / `offline-queue.ts` / `rebase.ts` | tier 3 |
|
package/README.md
CHANGED
|
@@ -196,6 +196,7 @@ wire.
|
|
|
196
196
|
| live subscriptions per socket | 128 | `new LiveQueryRegistry({ maxPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
|
|
197
197
|
| live subscriptions per tenant | unset | `new LiveQueryRegistry({ maxPerTenant, tenantOf })` — **both**, or it arms nothing | `X_SUBSCRIPTION_LIMIT` |
|
|
198
198
|
| distinct `(query, input)` pairs per node | 10,000 | `new LiveQueryRegistry({ maxEntries })` | `X_SUBSCRIPTION_LIMIT` |
|
|
199
|
+
| how long one entry's SHARED snapshot read may hold its slot | 30s | `new LiveQueryRegistry({ readDeadlineMs })` | `X_TIMEOUT`, to that read's caller AND every subscriber joined to it |
|
|
199
200
|
| channel topics per socket | 64 | `new ChannelHub({ maxTopicsPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
|
|
200
201
|
| distinct channel topics per node | 10,000 | `new ChannelHub({ maxTopicsPerNode })` | `X_SUBSCRIPTION_LIMIT` |
|
|
201
202
|
| outbound bytes buffered on one socket | 1 MiB | `createSyncNode({ maxBufferedBytes })` | the frame is dropped and `send` answers `false` |
|
|
@@ -543,7 +544,11 @@ wire twice by a reconnect that raced an ack.
|
|
|
543
544
|
`X_REPLICATION_FAILED` · `X_REPLICATION_PROTOCOL` · `X_REPLICATOR_SLOT_HELD` ·
|
|
544
545
|
`X_LIVE_CLIENT_MISSING` · `X_LIVE_SERVER_RENDER` · `X_LIVE_QUERY_UNKNOWN` ·
|
|
545
546
|
`X_LIVE_REPLICA_IDENTITY` ·
|
|
546
|
-
`X_SOCKET_UNAUTHENTICATED` · `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED`
|
|
547
|
+
`X_SOCKET_UNAUTHENTICATED` · `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED` ·
|
|
548
|
+
`X_TIMEOUT`
|
|
549
|
+
|
|
550
|
+
`X_NOT_IMPLEMENTED` and `X_TIMEOUT` are **borrowed** from `@ultimat3/core`, which owns and titles
|
|
551
|
+
them — `REALTIME_BORROWED_ERROR_CODES`. Everything else on that list is realtime's own.
|
|
547
552
|
|
|
548
553
|
Topics deny by default: a topic with no matching guard is forbidden. An authz hole is not a config
|
|
549
554
|
option someone forgot to set.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.2.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",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"test": "bun test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@ultimat3/core": "11.
|
|
40
|
-
"@ultimat3/query": "11.
|
|
39
|
+
"@ultimat3/core": "11.2.0",
|
|
40
|
+
"@ultimat3/query": "11.2.0",
|
|
41
41
|
"nats": "2.29.3"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/errors.ts
CHANGED
|
@@ -32,8 +32,13 @@ export const REALTIME_OWNED_ERROR_CODES = [
|
|
|
32
32
|
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s, and `X_FORBIDDEN` — thrown by the surface denials this
|
|
33
33
|
* package renders — is `@ultimat3/policy`'s. Neither is titled here: the owner writes the one title
|
|
34
34
|
* every surface renders, and a copy kept alongside it is a copy that goes stale unnoticed.
|
|
35
|
+
*
|
|
36
|
+
* `X_TIMEOUT` is core's too — titled in `CORE_CODE_TITLES` and classified `retryable` there, which
|
|
37
|
+
* is what a blown deadline owes a caller. Borrowed rather than owned for the same reason
|
|
38
|
+
* `@ultimat3/http` borrows it (`HTTP_BORROWED_ERROR_CODES`): the concept is core's, and a title
|
|
39
|
+
* registered here would throw `X_ERROR_CODE_DUPLICATE` at import.
|
|
35
40
|
*/
|
|
36
|
-
export const REALTIME_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
|
|
41
|
+
export const REALTIME_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED', 'X_TIMEOUT'] as const;
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
44
|
* The two codes an authz **decision** carries. Everything else a gate throws — a rule that reached
|
|
@@ -407,6 +412,24 @@ export class SocketAuthUnavailableError extends RealtimeError {
|
|
|
407
412
|
}
|
|
408
413
|
}
|
|
409
414
|
|
|
415
|
+
/**
|
|
416
|
+
* The shared window read for one live query id blew its deadline.
|
|
417
|
+
*
|
|
418
|
+
* Freeing the slot alone would leave every caller ALREADY joined to that read waiting on a promise
|
|
419
|
+
* nothing will ever settle, so they are TOLD instead — the same answer this file's other
|
|
420
|
+
* non-answering read gives, and the reason `fillWindow` needed no new branch to carry it. The read
|
|
421
|
+
* itself is not cancellable from here and this does not pretend to have stopped it.
|
|
422
|
+
*/
|
|
423
|
+
export class WindowReadTimeoutError extends RealtimeError {
|
|
424
|
+
constructor(args: { qid: string; afterMs: number }) {
|
|
425
|
+
super({
|
|
426
|
+
code: 'X_TIMEOUT',
|
|
427
|
+
cause: `the shared snapshot read for live query "${args.qid}" did not answer within ${args.afterMs}ms, so the window slot it held was released`,
|
|
428
|
+
fix: 'x doctor --json # then raise readDeadlineMs on the live query registry, or fix the snapshot read that stopped answering',
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
410
433
|
/** Deep infrastructure that is interface-complete but not wired. Carries the exact next step. */
|
|
411
434
|
export class NotImplementedError extends RealtimeError {
|
|
412
435
|
constructor(args: { what: string; fix: string }) {
|
package/src/index.ts
CHANGED
package/src/live-query.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type { SyncSocket } from './socket';
|
|
|
26
26
|
import { type Subscriber, SubscriberGate, type SubscriberGateOptions } from './subscriber-gate';
|
|
27
27
|
import { SubscriptionBook, subscriptionKey } from './subscription-book';
|
|
28
28
|
import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
29
|
+
import type { Scheduler } from './thundering-herd';
|
|
29
30
|
|
|
30
31
|
export interface LiveQueryRegistryOptions extends SubscriberGateOptions {
|
|
31
32
|
readonly source: ResumeSource;
|
|
@@ -40,6 +41,15 @@ export interface LiveQueryRegistryOptions extends SubscriberGateOptions {
|
|
|
40
41
|
* a `WindowLock` and a fanout target each — until the process dies.
|
|
41
42
|
*/
|
|
42
43
|
readonly maxEntries?: number;
|
|
44
|
+
/**
|
|
45
|
+
* How long one entry's SHARED snapshot read may hold its slot. Defaults to
|
|
46
|
+
* `DEFAULT_READ_DEADLINE_MS`. Without it a `definition.snapshot` that never settles pinned the
|
|
47
|
+
* slot for the life of the process and every later cold subscriber joined a promise nothing
|
|
48
|
+
* would resolve — one wedged read taking every future subscriber of that query id with it.
|
|
49
|
+
*/
|
|
50
|
+
readonly readDeadlineMs?: number;
|
|
51
|
+
/** Injected so that deadline is provable without waiting for one. Production uses `setTimeout`. */
|
|
52
|
+
readonly schedule?: Scheduler;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
/**
|
|
@@ -384,7 +394,10 @@ export class LiveQueryRegistry {
|
|
|
384
394
|
knob: 'maxEntries',
|
|
385
395
|
});
|
|
386
396
|
}
|
|
387
|
-
const created = createEntry(qid, definition, input, definition.matcher(input)
|
|
397
|
+
const created = createEntry(qid, definition, input, definition.matcher(input), {
|
|
398
|
+
readDeadlineMs: this.#options.readDeadlineMs,
|
|
399
|
+
schedule: this.#options.schedule,
|
|
400
|
+
});
|
|
388
401
|
this.#entries.set(qid, created);
|
|
389
402
|
return created;
|
|
390
403
|
}
|
package/src/query-window.ts
CHANGED
|
@@ -2,11 +2,30 @@
|
|
|
2
2
|
// read once for N subscribers, and how one that is known to be wrong is replaced. The authz
|
|
3
3
|
// decision is never here — `live-query.ts` owns that, once per subscriber, over what this returns.
|
|
4
4
|
|
|
5
|
+
import { WindowReadTimeoutError } from './errors';
|
|
5
6
|
import type { JsonValue, Row } from './json';
|
|
6
7
|
import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
|
|
7
8
|
import type { IncrementalMatcher, SubscriptionShape } from './matcher-bridge';
|
|
9
|
+
import type { Scheduler } from './thundering-herd';
|
|
10
|
+
import { timeoutScheduler } from './thundering-herd';
|
|
8
11
|
import { WindowLock } from './window-lock';
|
|
9
12
|
|
|
13
|
+
/**
|
|
14
|
+
* How long the SHARED read may hold the slot before it is released. On by default and with no
|
|
15
|
+
* "off" spelling: a shared read with no deadline is exactly the defect this closes — one
|
|
16
|
+
* `definition.snapshot` that never settles pinned `entry.reading` for the life of the process and
|
|
17
|
+
* every later cold subscriber joined a promise nothing would resolve. A caller that wants a longer
|
|
18
|
+
* one names a bigger number.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_READ_DEADLINE_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
export interface EntryOptions {
|
|
23
|
+
/** Non-finite or non-positive falls back to the default — `0` cannot mean both "now" and "never". */
|
|
24
|
+
readonly readDeadlineMs?: number | undefined;
|
|
25
|
+
/** Injected so a deadline is provable without waiting for one. */
|
|
26
|
+
readonly schedule?: Scheduler | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
export interface QueryEntry {
|
|
11
30
|
readonly qid: string;
|
|
12
31
|
readonly definition: LiveQueryDefinition;
|
|
@@ -40,6 +59,8 @@ export interface QueryEntry {
|
|
|
40
59
|
generation: number;
|
|
41
60
|
/** The generation of the newest read whose rows are in `rows`. `0` before the first one lands. */
|
|
42
61
|
applied: number;
|
|
62
|
+
readonly readDeadlineMs: number;
|
|
63
|
+
readonly schedule: Scheduler;
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
/**
|
|
@@ -57,6 +78,7 @@ export function createEntry(
|
|
|
57
78
|
definition: LiveQueryDefinition,
|
|
58
79
|
input: JsonValue,
|
|
59
80
|
matcher: IncrementalMatcher,
|
|
81
|
+
options?: EntryOptions,
|
|
60
82
|
): QueryEntry {
|
|
61
83
|
return {
|
|
62
84
|
qid,
|
|
@@ -83,6 +105,8 @@ export function createEntry(
|
|
|
83
105
|
reading: null,
|
|
84
106
|
generation: 0,
|
|
85
107
|
applied: 0,
|
|
108
|
+
readDeadlineMs: usableDeadline(options?.readDeadlineMs),
|
|
109
|
+
schedule: options?.schedule ?? timeoutScheduler,
|
|
86
110
|
};
|
|
87
111
|
}
|
|
88
112
|
|
|
@@ -145,15 +169,54 @@ function applyRead(entry: QueryEntry, pending: PendingRead, result: SnapshotResu
|
|
|
145
169
|
if (result.lsn > entry.lsn) entry.lsn = result.lsn;
|
|
146
170
|
}
|
|
147
171
|
|
|
148
|
-
|
|
172
|
+
function usableDeadline(declared: number | undefined): number {
|
|
173
|
+
if (declared === undefined) return DEFAULT_READ_DEADLINE_MS;
|
|
174
|
+
return Number.isFinite(declared) && declared > 0 ? declared : DEFAULT_READ_DEADLINE_MS;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Publishes the in-flight read, and clears it as it settles — the share is per read, not a cache.
|
|
179
|
+
*
|
|
180
|
+
* The deadline frees the SLOT. It cannot cancel the read — nothing here can, and pretending
|
|
181
|
+
* otherwise would be a second, false promise — so the abandoned read runs to completion with
|
|
182
|
+
* nobody listening, and the next caller issues its own instead of joining a corpse.
|
|
183
|
+
*/
|
|
149
184
|
function startRead(entry: QueryEntry): PendingRead {
|
|
150
185
|
// Cleared here rather than when the read lands: the read about to be issued is the one that
|
|
151
186
|
// answers the staleness, so a second caller must join it instead of forcing another.
|
|
152
187
|
entry.stale = false;
|
|
153
188
|
entry.generation += 1;
|
|
154
|
-
|
|
189
|
+
|
|
190
|
+
const read = readSnapshot(entry);
|
|
191
|
+
let expire: () => void = noop;
|
|
192
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
193
|
+
// Assigned synchronously — a Promise executor runs before the constructor returns — so `done`
|
|
194
|
+
// can never fire against the placeholder and leave a timer armed.
|
|
195
|
+
expire = entry.schedule(() => {
|
|
196
|
+
// The same restoration `readSnapshot`'s catch makes, and for the same reason: a read that
|
|
197
|
+
// did not answer must not leave the window looking authoritative. Unconditional even when a
|
|
198
|
+
// NEWER read already holds the slot, which can only cost one extra read — over-reading is
|
|
199
|
+
// safe here and under-reading is the divergence `stale` exists to prevent.
|
|
200
|
+
entry.stale = true;
|
|
201
|
+
reject(new WindowReadTimeoutError({ qid: entry.qid, afterMs: entry.readDeadlineMs }));
|
|
202
|
+
}, entry.readDeadlineMs);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const reading: PendingRead = {
|
|
206
|
+
generation: entry.generation,
|
|
207
|
+
// A race, not just an eviction: freeing the slot alone would leave every caller ALREADY joined
|
|
208
|
+
// awaiting a promise nothing settles. `Promise.race` subscribes to both, so a read that
|
|
209
|
+
// rejects AFTER losing the race is still handled and never surfaces as an unhandled rejection.
|
|
210
|
+
result: Promise.race([read, deadline]),
|
|
211
|
+
};
|
|
155
212
|
entry.reading = reading;
|
|
213
|
+
|
|
156
214
|
const done = (): void => {
|
|
215
|
+
// An armed timer per completed read keeps the process alive and is a leak in its own right.
|
|
216
|
+
expire();
|
|
217
|
+
// Identity, never presence — the rule the deadline makes load-bearing rather than merely
|
|
218
|
+
// careful: a read that answers after its deadline released the slot must not clear whatever
|
|
219
|
+
// holds it now, or that newer read's joiners share a promise the entry no longer answers for.
|
|
157
220
|
if (entry.reading === reading) entry.reading = null;
|
|
158
221
|
};
|
|
159
222
|
void reading.result.then(done, done);
|
|
@@ -185,3 +248,5 @@ export function orgIdOf(input: JsonValue): string | null {
|
|
|
185
248
|
const value = input['orgId'];
|
|
186
249
|
return typeof value === 'string' ? value : null;
|
|
187
250
|
}
|
|
251
|
+
|
|
252
|
+
const noop = (): void => undefined;
|
package/src/server.ts
CHANGED
package/src/thundering-herd.ts
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
// 2. backoffDelay() — 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 {
|
|
9
|
+
import {
|
|
10
|
+
type Clock,
|
|
11
|
+
backoffDelay as coreBackoffDelay,
|
|
12
|
+
type JitterMode,
|
|
13
|
+
type Random,
|
|
14
|
+
systemClock,
|
|
15
|
+
} from '@ultimat3/core';
|
|
10
16
|
import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
11
17
|
|
|
12
18
|
/** Injected so tests are deterministic and `local` mutators stay replayable. */
|
|
13
|
-
export type Rng =
|
|
19
|
+
export type Rng = Random;
|
|
14
20
|
|
|
15
|
-
|
|
21
|
+
/** Core's, re-exported under this package's name — never a second copy of the same three modes. */
|
|
22
|
+
export type { JitterMode };
|
|
16
23
|
|
|
17
24
|
export interface BackoffPolicy {
|
|
18
25
|
readonly baseMs: number;
|
|
@@ -32,21 +39,28 @@ export const defaultBackoff: BackoffPolicy = {
|
|
|
32
39
|
jitter: 'full',
|
|
33
40
|
};
|
|
34
41
|
|
|
35
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Attempt is 0-BASED. Result is always in `[0, maxMs]`.
|
|
44
|
+
*
|
|
45
|
+
* The arithmetic is core's, and `attempt + 1` is the WHOLE of the seam: a client counts its first
|
|
46
|
+
* reconnect as attempt 0 and core counts the first wait as attempt 1, so dropping the shift would
|
|
47
|
+
* double every reconnect delay in the framework — silently, and only under the load this file
|
|
48
|
+
* exists to survive. `thundering-herd-core-parity.test.ts` pins the numbers.
|
|
49
|
+
*/
|
|
36
50
|
export function backoffDelay(
|
|
37
51
|
attempt: number,
|
|
38
52
|
policy: BackoffPolicy = defaultBackoff,
|
|
39
53
|
rng: Rng = Math.random,
|
|
40
54
|
): number {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
55
|
+
return coreBackoffDelay({
|
|
56
|
+
attempt: attempt + 1,
|
|
57
|
+
base: policy.baseMs,
|
|
58
|
+
max: policy.maxMs,
|
|
59
|
+
factor: policy.factor,
|
|
60
|
+
curve: 'exponential',
|
|
61
|
+
jitter: policy.jitter,
|
|
62
|
+
random: rng,
|
|
63
|
+
});
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
/**
|