@lazily-hub/lazily-js 0.15.0 → 0.16.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/README.md +2 -0
- package/package.json +10 -2
- package/src/merge.d.ts +50 -0
- package/src/merge.js +142 -0
- package/src/relay.d.ts +184 -0
- package/src/relay.js +622 -0
package/README.md
CHANGED
|
@@ -51,6 +51,8 @@ notes and platform carve-outs lives in
|
|
|
51
51
|
| Memoized semantic tree (`SemTree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
52
52
|
| Stable-id alignment (manufactured identity) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
53
53
|
| Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
54
|
+
| Merge algebra + `MergeCell` — associative `MergePolicy` (`KeepLatest`/`Sum`/`Max`/`SetUnion`/`RawFifo`), `Cell ≡ MergeCell<KeepLatest>`, `Reactive`/`Source` split (`#relaycell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
55
|
+
| RelayCell — conflating relay + `BackpressurePolicy` + `SpillStore` + `Transport` + Inbox/Outbox + Rate/Window/Expiry/Priority/keyed policies (`#relaycell`) | ✅ | — | — | ✅ | — | — | — | — |
|
|
54
56
|
| Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
55
57
|
| `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
56
58
|
| Move-aware sequence CRDT (`SeqCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazily-hub/lazily-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Native JavaScript port of the lazily reactive core: a full reactive graph (Cell/Slot/Signal/Effect), sync/async/thread-safe keyed reactive maps (ReactiveMap/CellMap/SlotMap), the lazily-spec IPC wire types + C-ABI FFI boundary (isomorphic channel + Node native binding), keyed cell collections + LIS reconciliation, the memoized semantic tree, the move-aware sequence CRDT, the Fugue/RGA text CRDT, manufactured text identity, full-Harel state charts, an FFI state-projection consumer, and an in-library instrumentation/benchmark API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -69,6 +69,14 @@
|
|
|
69
69
|
"types": "./src/queue.d.ts",
|
|
70
70
|
"default": "./src/queue.js"
|
|
71
71
|
},
|
|
72
|
+
"./merge": {
|
|
73
|
+
"types": "./src/merge.d.ts",
|
|
74
|
+
"default": "./src/merge.js"
|
|
75
|
+
},
|
|
76
|
+
"./relay": {
|
|
77
|
+
"types": "./src/relay.d.ts",
|
|
78
|
+
"default": "./src/relay.js"
|
|
79
|
+
},
|
|
72
80
|
"./sem-tree": {
|
|
73
81
|
"types": "./src/sem-tree.d.ts",
|
|
74
82
|
"default": "./src/sem-tree.js"
|
|
@@ -103,7 +111,7 @@
|
|
|
103
111
|
}
|
|
104
112
|
},
|
|
105
113
|
"scripts": {
|
|
106
|
-
"build": "node --check src/index.js && node --check src/transport.js && node --check src/shm-backend.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/reactive-family.js && node --check src/async-reactive-family.js && node --check src/thread-safe.js && node --check src/thread-safe-reactive-family.js && node --check src/ffi.js && node --check src/ffi-node.js && node --check src/instrumentation.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/queue.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/graph-view.js && node --check src/signaling.js && node --check src/distributed.js",
|
|
114
|
+
"build": "node --check src/index.js && node --check src/transport.js && node --check src/shm-backend.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/reactive-family.js && node --check src/async-reactive-family.js && node --check src/thread-safe.js && node --check src/thread-safe-reactive-family.js && node --check src/ffi.js && node --check src/ffi-node.js && node --check src/instrumentation.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/queue.js && node --check src/merge.js && node --check src/relay.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/graph-view.js && node --check src/signaling.js && node --check src/distributed.js",
|
|
107
115
|
"test:formal": "node scripts/formal-check.mjs",
|
|
108
116
|
"bench": "node bench/context.bench.mjs",
|
|
109
117
|
"bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
|
package/src/merge.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Phase 1 of the RelayCell backpressure plan (#relaycell) — merge algebra +
|
|
2
|
+
// the Reactive/Source read/write split. See lazily-spec/docs/reactive-graph.md
|
|
3
|
+
// § "MergeCell and the merge algebra".
|
|
4
|
+
|
|
5
|
+
import type { CellHandle, Context } from "./reactive.js";
|
|
6
|
+
|
|
7
|
+
/** An associative merge policy `⊕: T×T→T` with its transport-selected flags. */
|
|
8
|
+
export interface MergePolicy<T> {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
/** The associative fold. MUST satisfy `(a⊕b)⊕c == a⊕(b⊕c)`. */
|
|
11
|
+
merge(old: T, op: T): T;
|
|
12
|
+
/** `⊕` is commutative (reordering tax). */
|
|
13
|
+
readonly commutative: boolean;
|
|
14
|
+
/** `⊕` is idempotent — `(a⊕b)⊕b == a⊕b` (durability tax). */
|
|
15
|
+
readonly idempotent: boolean;
|
|
16
|
+
/** Conflation bounds the state (the `Conflate` overflow precondition). */
|
|
17
|
+
readonly conflates: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const KeepLatest: MergePolicy<unknown>;
|
|
21
|
+
export const Sum: MergePolicy<number>;
|
|
22
|
+
export const Max: MergePolicy<number>;
|
|
23
|
+
export const SetUnion: MergePolicy<Set<unknown>>;
|
|
24
|
+
export const RawFifo: MergePolicy<unknown[]>;
|
|
25
|
+
|
|
26
|
+
/** A cell whose write is a merge under `policy` (`Cell ≡ MergeCell(KeepLatest)`). */
|
|
27
|
+
export class MergeCell<T> {
|
|
28
|
+
readonly ctx: Context;
|
|
29
|
+
readonly cell: CellHandle<T>;
|
|
30
|
+
readonly policy: MergePolicy<T>;
|
|
31
|
+
constructor(ctx: Context, cell: CellHandle<T>, policy: MergePolicy<T>);
|
|
32
|
+
/** Read the current converged value. */
|
|
33
|
+
get(): T;
|
|
34
|
+
/** Replace the value (keep-latest write). */
|
|
35
|
+
set(value: T): void;
|
|
36
|
+
/** Fold `op` into the value under the policy. */
|
|
37
|
+
merge(op: T): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Create a `MergeCell` over `ctx`. */
|
|
41
|
+
export function mergeCell<T>(ctx: Context, initial: T, policy: MergePolicy<T>): MergeCell<T>;
|
|
42
|
+
|
|
43
|
+
/** Adapt a plain cell to the `Source` shape (get/set/merge; merge == replace). */
|
|
44
|
+
export function asSource<T>(
|
|
45
|
+
ctx: Context,
|
|
46
|
+
cellHandle: CellHandle<T>,
|
|
47
|
+
): { get(): T; set(value: T): void; merge(op: T): void };
|
|
48
|
+
|
|
49
|
+
/** Adapt any read handle to the `Reactive` shape (`{ get }`). */
|
|
50
|
+
export function asReactive<T>(ctx: Context, handle: unknown): { get(): T };
|
package/src/merge.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Phase 1 of the RelayCell backpressure plan (#relaycell) — the merge algebra
|
|
2
|
+
// and the Reactive/Source read/write split.
|
|
3
|
+
//
|
|
4
|
+
// See lazily-spec/docs/reactive-graph.md § "MergeCell and the merge algebra" and
|
|
5
|
+
// relaycell-backpressure-analysis.md §4.0/§4.3. A merge policy is an *associative*
|
|
6
|
+
// fold ⊕: T×T→T; the properties it satisfies (associativity always; commutativity
|
|
7
|
+
// = reordering tax; idempotency = durability tax) select which overflow behaviour
|
|
8
|
+
// is sound. `MergeCell` generalizes a plain `Cell` — `Cell ≡ MergeCell(KeepLatest)`
|
|
9
|
+
// — a source whose write is a merge. Backed by an ordinary cell, so it inherits
|
|
10
|
+
// the Phase-0 `==` store-guard and store-without-cascade.
|
|
11
|
+
|
|
12
|
+
// -- Merge policies ----------------------------------------------------------
|
|
13
|
+
//
|
|
14
|
+
// A policy is a plain object: { name, merge(old, op), commutative, idempotent,
|
|
15
|
+
// conflates }. Associativity is a law (verified by law-tests), not a field. The
|
|
16
|
+
// three flags surface the transport-selected branches; `conflates` gates the
|
|
17
|
+
// `Conflate` overflow (only RawFifo — concat — cannot bound, Phase 2).
|
|
18
|
+
|
|
19
|
+
/** Keep-latest band: `old ⊕ op = op`. The policy behind a plain `Cell`. */
|
|
20
|
+
export const KeepLatest = Object.freeze({
|
|
21
|
+
name: "KeepLatest",
|
|
22
|
+
merge: (_old, op) => op,
|
|
23
|
+
commutative: false,
|
|
24
|
+
idempotent: true,
|
|
25
|
+
conflates: true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** Additive commutative monoid: `old + op`. Not idempotent. */
|
|
29
|
+
export const Sum = Object.freeze({
|
|
30
|
+
name: "Sum",
|
|
31
|
+
merge: (old, op) => old + op,
|
|
32
|
+
commutative: true,
|
|
33
|
+
idempotent: false,
|
|
34
|
+
conflates: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Max semilattice: `max(old, op)`. Associative, commutative, idempotent. */
|
|
38
|
+
export const Max = Object.freeze({
|
|
39
|
+
name: "Max",
|
|
40
|
+
merge: (old, op) => (op > old ? op : old),
|
|
41
|
+
commutative: true,
|
|
42
|
+
idempotent: true,
|
|
43
|
+
conflates: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Grow-only set-union semilattice over `Set`. */
|
|
47
|
+
export const SetUnion = Object.freeze({
|
|
48
|
+
name: "SetUnion",
|
|
49
|
+
merge: (old, op) => {
|
|
50
|
+
const out = new Set(old);
|
|
51
|
+
for (const x of op) out.add(x);
|
|
52
|
+
return out;
|
|
53
|
+
},
|
|
54
|
+
commutative: true,
|
|
55
|
+
idempotent: true,
|
|
56
|
+
conflates: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Raw FIFO append over arrays: `old ++ op`. Order + multiplicity are meaning —
|
|
60
|
+
* associative only; cannot conflate. */
|
|
61
|
+
export const RawFifo = Object.freeze({
|
|
62
|
+
name: "RawFifo",
|
|
63
|
+
merge: (old, op) => old.concat(op),
|
|
64
|
+
commutative: false,
|
|
65
|
+
idempotent: false,
|
|
66
|
+
conflates: false,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// -- MergeCell ---------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A cell whose write is a *merge* under `policy`, rather than a replace.
|
|
73
|
+
* `Cell ≡ MergeCell(KeepLatest)`.
|
|
74
|
+
*/
|
|
75
|
+
export class MergeCell {
|
|
76
|
+
/** @param {import("./reactive.js").Context} ctx */
|
|
77
|
+
constructor(ctx, cell, policy) {
|
|
78
|
+
this.ctx = ctx;
|
|
79
|
+
/** underlying CellHandle */
|
|
80
|
+
this.cell = cell;
|
|
81
|
+
this.policy = policy;
|
|
82
|
+
Object.freeze(this);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Read the current converged value (tracks a dependency inside a computation). */
|
|
86
|
+
get() {
|
|
87
|
+
return this.ctx.getCell(this.cell);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Replace the value outright (the keep-latest write), bypassing the policy. */
|
|
91
|
+
set(value) {
|
|
92
|
+
this.ctx.setCell(this.cell, value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Fold `op` into the current value under the policy. Routes through `setCell`
|
|
96
|
+
* so the `==` store-guard (free dedup for idempotent ⊕) + store-without-cascade
|
|
97
|
+
* apply unchanged. */
|
|
98
|
+
merge(op) {
|
|
99
|
+
const old = this.ctx.getCell(this.cell);
|
|
100
|
+
this.ctx.setCell(this.cell, this.policy.merge(old, op));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Create a `MergeCell` over `ctx` with `initial` value under `policy`. */
|
|
105
|
+
export function mergeCell(ctx, initial, policy) {
|
|
106
|
+
return new MergeCell(ctx, ctx.cell(initial), policy);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// -- Reactive / Source -------------------------------------------------------
|
|
110
|
+
//
|
|
111
|
+
// JavaScript is structurally typed, so the `Reactive<T>` read supertype and the
|
|
112
|
+
// `Source<T>: Reactive<T>` write sub-interface are documented shapes rather than
|
|
113
|
+
// declared types. A reader (Slot/Signal) exposes { get }; a source (Cell/MergeCell)
|
|
114
|
+
// exposes { get, set, merge }. `asSource` adapts a plain CellHandle to the Source
|
|
115
|
+
// shape (its `merge` is a replace — Cell ≡ MergeCell(KeepLatest)).
|
|
116
|
+
|
|
117
|
+
/** Adapt a plain `CellHandle` to the `Source` shape (get/set/merge). */
|
|
118
|
+
export function asSource(ctx, cellHandle) {
|
|
119
|
+
return {
|
|
120
|
+
get: () => ctx.getCell(cellHandle),
|
|
121
|
+
set: (value) => ctx.setCell(cellHandle, value),
|
|
122
|
+
// Cell ≡ MergeCell(KeepLatest): merge replaces.
|
|
123
|
+
merge: (op) => ctx.setCell(cellHandle, op),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Adapt any read handle (Slot/Signal/Cell) to the `Reactive` shape ({ get }). */
|
|
128
|
+
export function asReactive(ctx, handle) {
|
|
129
|
+
// Cells read via getCell; slots/signals via get/getSignal. Detect by shape.
|
|
130
|
+
if (handle instanceof MergeCell) return { get: () => handle.get() };
|
|
131
|
+
if (handle && handle.slot !== undefined) return { get: () => ctx.getSignal(handle) };
|
|
132
|
+
// Fall back: try getCell then get.
|
|
133
|
+
return {
|
|
134
|
+
get: () => {
|
|
135
|
+
try {
|
|
136
|
+
return ctx.getCell(handle);
|
|
137
|
+
} catch {
|
|
138
|
+
return ctx.get(handle);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/relay.d.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// RelayCell backpressure plan (#relaycell), Phases 2–6. See
|
|
2
|
+
// lazily-spec/docs/relaycell.md and relaycell-backpressure-analysis.md.
|
|
3
|
+
|
|
4
|
+
import type { CellHandle, Context, SlotHandle } from "./reactive.js";
|
|
5
|
+
import type { MergePolicy } from "./merge.js";
|
|
6
|
+
|
|
7
|
+
// -- Phase 2: RelayCell + BackpressurePolicy ---------------------------------
|
|
8
|
+
|
|
9
|
+
export type BoundDim = "Count" | "Bytes" | "Keys" | "Age";
|
|
10
|
+
export const BoundDim: { readonly Count: "Count"; readonly Bytes: "Bytes"; readonly Keys: "Keys"; readonly Age: "Age" };
|
|
11
|
+
|
|
12
|
+
export type Overflow = "Block" | "DropNewest" | "DropOldest" | "Conflate" | "Spill";
|
|
13
|
+
export const Overflow: {
|
|
14
|
+
readonly Block: "Block";
|
|
15
|
+
readonly DropNewest: "DropNewest";
|
|
16
|
+
readonly DropOldest: "DropOldest";
|
|
17
|
+
readonly Conflate: "Conflate";
|
|
18
|
+
readonly Spill: "Spill";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const RelayConfigError: { readonly ConflateNotBounding: "ConflateNotBounding" };
|
|
22
|
+
|
|
23
|
+
export type IngressOutcome = "Accepted" | "Conflated" | "Dropped" | "Blocked";
|
|
24
|
+
export const IngressOutcome: {
|
|
25
|
+
readonly Accepted: "Accepted";
|
|
26
|
+
readonly Conflated: "Conflated";
|
|
27
|
+
readonly Dropped: "Dropped";
|
|
28
|
+
readonly Blocked: "Blocked";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Reactive backpressure limits (analysis §4.4). Every field is a cell. */
|
|
32
|
+
export class BackpressurePolicy {
|
|
33
|
+
readonly ctx: Context;
|
|
34
|
+
readonly dimension: CellHandle<BoundDim>;
|
|
35
|
+
readonly highWater: CellHandle<number>;
|
|
36
|
+
readonly lowWater: CellHandle<number>;
|
|
37
|
+
readonly overflow: CellHandle<Overflow>;
|
|
38
|
+
constructor(ctx: Context, dimension: BoundDim, highWater: number, lowWater: number, overflow: Overflow);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The algebra-typed conflating relay (Phase 2, in-proc core). */
|
|
42
|
+
export class RelayCell<T> {
|
|
43
|
+
constructor(ctx: Context, policy: BackpressurePolicy, mergePolicy: MergePolicy<T>);
|
|
44
|
+
overflowIsLegal(): boolean;
|
|
45
|
+
/** Current window depth (Count). */
|
|
46
|
+
depth(): number;
|
|
47
|
+
/** Window is at/over high_water. */
|
|
48
|
+
isFull(): boolean;
|
|
49
|
+
/** Window is empty (nothing to drain). */
|
|
50
|
+
isEmpty(): boolean;
|
|
51
|
+
depthSlot(): SlotHandle<number>;
|
|
52
|
+
isFullSlot(): SlotHandle<boolean>;
|
|
53
|
+
isEmptySlot(): SlotHandle<boolean>;
|
|
54
|
+
/** Ingest one op under the merge/overflow policy. */
|
|
55
|
+
ingress(op: T): IngressOutcome;
|
|
56
|
+
/** Drain the coalesced window (null when empty). */
|
|
57
|
+
drain(): T | null;
|
|
58
|
+
/** Peek the current coalesced window without draining. */
|
|
59
|
+
peek(): T | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// -- Phase 3: SpillStore -----------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export type SpillMode = "CompactOnWrite" | "AppendCompact";
|
|
65
|
+
export const SpillMode: { readonly CompactOnWrite: "CompactOnWrite"; readonly AppendCompact: "AppendCompact" };
|
|
66
|
+
|
|
67
|
+
/** A paged durable tail for a RelayCell (Phase 3, in-memory reference backend). */
|
|
68
|
+
export class SpillStore<T> {
|
|
69
|
+
constructor(mode: SpillMode, pageSize: number, mergePolicy: MergePolicy<T>);
|
|
70
|
+
spill(window: T, bytes: number): void;
|
|
71
|
+
/** The manifest: [id, bytes] for every live page. */
|
|
72
|
+
manifest(): [number, number][];
|
|
73
|
+
pendingPages(): { id: number; summary: T; bytes: number }[];
|
|
74
|
+
pageCount(): number;
|
|
75
|
+
ackThrough(id: number): void;
|
|
76
|
+
reclaim(): void;
|
|
77
|
+
foldPages(s0: T): T;
|
|
78
|
+
/** Reconstruction (spill_lossless): fold cold tail then hot head. */
|
|
79
|
+
reconstruct(s0: T, hot: T | null): T;
|
|
80
|
+
/** Crash replay of unacked pages (idempotent for an idempotent policy). */
|
|
81
|
+
replayUnacked(downstream: T): T;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// -- Phase 4: Transport ------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
export interface RelayTransport<T> {
|
|
87
|
+
deliver(op: T): void;
|
|
88
|
+
poll(): T[];
|
|
89
|
+
hasPending(): boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** InProc — direct delivery: every buffered op in one frame. */
|
|
93
|
+
export class InProcTransport<T> implements RelayTransport<T> {
|
|
94
|
+
deliver(op: T): void;
|
|
95
|
+
poll(): T[];
|
|
96
|
+
hasPending(): boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Framed transport — bounded frames of at most frameSize (MTU/batch). */
|
|
100
|
+
export class FramedTransport<T> implements RelayTransport<T> {
|
|
101
|
+
readonly frameSize: number;
|
|
102
|
+
constructor(frameSize: number);
|
|
103
|
+
deliver(op: T): void;
|
|
104
|
+
poll(): T[];
|
|
105
|
+
hasPending(): boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// -- Phase 5: Outbox / Inbox roles -------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** app → transport send side; backpressures the local producer via is_full. */
|
|
111
|
+
export class Outbox<T> {
|
|
112
|
+
constructor(
|
|
113
|
+
ctx: Context,
|
|
114
|
+
highWater: number,
|
|
115
|
+
mergePolicy: MergePolicy<T>,
|
|
116
|
+
opts?: { dimension?: BoundDim; overflow?: Overflow },
|
|
117
|
+
);
|
|
118
|
+
send(op: T): IngressOutcome;
|
|
119
|
+
drain(): T | null;
|
|
120
|
+
isFull(): boolean;
|
|
121
|
+
isFullSlot(): SlotHandle<boolean>;
|
|
122
|
+
relay(): RelayCell<T>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** transport → app receive side; credit meter throttles the remote. */
|
|
126
|
+
export class Inbox<T> {
|
|
127
|
+
constructor(
|
|
128
|
+
ctx: Context,
|
|
129
|
+
highWater: number,
|
|
130
|
+
maxCredits: number,
|
|
131
|
+
mergePolicy: MergePolicy<T>,
|
|
132
|
+
opts?: { overflow?: Overflow },
|
|
133
|
+
);
|
|
134
|
+
ready(): boolean;
|
|
135
|
+
credits(): number;
|
|
136
|
+
receive(op: T): IngressOutcome;
|
|
137
|
+
consume(replenish: number): T | null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// -- Phase 6: extra reactive policies ----------------------------------------
|
|
141
|
+
|
|
142
|
+
/** Rate-limited egress (token bucket). */
|
|
143
|
+
export class RatePolicy {
|
|
144
|
+
readonly capacity: number;
|
|
145
|
+
readonly refillPerTick: number;
|
|
146
|
+
constructor(capacity: number, refillPerTick: number);
|
|
147
|
+
tokens(): number;
|
|
148
|
+
tryEgress(): boolean;
|
|
149
|
+
tick(): void;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Time-windowed coalescence (debounce/throttle). */
|
|
153
|
+
export class WindowPolicy {
|
|
154
|
+
readonly windowOps: number;
|
|
155
|
+
constructor(windowOps: number);
|
|
156
|
+
onIngress(): boolean;
|
|
157
|
+
tick(): boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** TTL / deadline expiry over a logical clock. */
|
|
161
|
+
export class ExpiryPolicy {
|
|
162
|
+
readonly ttl: number;
|
|
163
|
+
constructor(ttl: number);
|
|
164
|
+
advance(by: number): void;
|
|
165
|
+
now(): number;
|
|
166
|
+
isLive(stampedAt: number): boolean;
|
|
167
|
+
retainLive<T>(batch: [number, T][]): T[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Priority egress — highest priority first, FIFO within priority. */
|
|
171
|
+
export class PriorityStorage<T> {
|
|
172
|
+
push(priority: number, value: T): void;
|
|
173
|
+
pop(): T | null;
|
|
174
|
+
readonly length: number;
|
|
175
|
+
isEmpty(): boolean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Keyed sharding — N independent relays keyed by K. */
|
|
179
|
+
export class KeyedRelay<K, T> {
|
|
180
|
+
constructor(ctx: Context, highWater: number, overflow: Overflow, mergePolicy: MergePolicy<T>);
|
|
181
|
+
ingress(key: K, op: T): IngressOutcome;
|
|
182
|
+
drain(key: K): T | null;
|
|
183
|
+
keys(): IterableIterator<K>;
|
|
184
|
+
}
|
package/src/relay.js
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// RelayCell backpressure plan (#relaycell), Phases 2–6 — the stackless JS port.
|
|
2
|
+
//
|
|
3
|
+
// See lazily-spec/docs/relaycell.md and relaycell-backpressure-analysis.md. A
|
|
4
|
+
// RelayCell is an *algebra-typed conflating relay*: it accumulates a fast ingress
|
|
5
|
+
// into a hot head (a MergePolicy fold), bounds it with a reactive
|
|
6
|
+
// BackpressurePolicy, and lets a slow egress drain the coalesced window. The
|
|
7
|
+
// converged egress state is independent of the drain schedule whenever the merge
|
|
8
|
+
// ⊕ is associative (the relay_converges invariant, pinned in LazilyFormal.Relay).
|
|
9
|
+
//
|
|
10
|
+
// Phase 2 RelayCell + BackpressurePolicy · Phase 3 SpillStore · Phase 4 Transport
|
|
11
|
+
// · Phase 5 Outbox/Inbox roles · Phase 6 Rate/Window/Expiry/Priority/Keyed
|
|
12
|
+
// policies. Time is a logical clock (a monotone tick) so behaviour is
|
|
13
|
+
// deterministic and portable.
|
|
14
|
+
|
|
15
|
+
// -- Phase 2: RelayCell + BackpressurePolicy ---------------------------------
|
|
16
|
+
|
|
17
|
+
/** What a bound measures (analysis §4.4). The core meters Count. */
|
|
18
|
+
export const BoundDim = Object.freeze({
|
|
19
|
+
Count: "Count",
|
|
20
|
+
Bytes: "Bytes",
|
|
21
|
+
Keys: "Keys",
|
|
22
|
+
Age: "Age",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** The action taken when the hot head crosses high_water (analysis §4.4). */
|
|
26
|
+
export const Overflow = Object.freeze({
|
|
27
|
+
/** Refuse ingress; the producer backpressures (observes is_full). Lossless. */
|
|
28
|
+
Block: "Block",
|
|
29
|
+
/** Discard the incoming op. Lossy. */
|
|
30
|
+
DropNewest: "DropNewest",
|
|
31
|
+
/** Reset the window to the incoming op, discarding what accumulated. Lossy. */
|
|
32
|
+
DropOldest: "DropOldest",
|
|
33
|
+
/** Keep merging — the coalescence *is* the bound. Requires policy.conflates. */
|
|
34
|
+
Conflate: "Conflate",
|
|
35
|
+
/** Page the accumulated window to a durable tail (Phase 3 SpillStore). */
|
|
36
|
+
Spill: "Spill",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Why a construction/merge-swap was rejected (analysis §4.3 flag validation). */
|
|
40
|
+
export const RelayConfigError = Object.freeze({
|
|
41
|
+
/** Conflate chosen for a non-conflating policy (RawFifo). */
|
|
42
|
+
ConflateNotBounding: "ConflateNotBounding",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** The outcome of a single ingress op. */
|
|
46
|
+
export const IngressOutcome = Object.freeze({
|
|
47
|
+
/** Merged into an empty window (window depth was 0). */
|
|
48
|
+
Accepted: "Accepted",
|
|
49
|
+
/** Merged into a non-empty window (coalesced with prior ops). */
|
|
50
|
+
Conflated: "Conflated",
|
|
51
|
+
/** Dropped by DropNewest/DropOldest overflow. */
|
|
52
|
+
Dropped: "Dropped",
|
|
53
|
+
/** Refused by Block overflow; the producer must retry after a drain. */
|
|
54
|
+
Blocked: "Blocked",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reactive backpressure limits (analysis §4.4). Every field is a cell, so an
|
|
59
|
+
* operator or an adaptive controller retunes it live and dependent relays react.
|
|
60
|
+
* Hysteresis (high_water ≠ low_water) prevents flapping.
|
|
61
|
+
*/
|
|
62
|
+
export class BackpressurePolicy {
|
|
63
|
+
/** @param {import("./reactive.js").Context} ctx */
|
|
64
|
+
constructor(ctx, dimension, highWater, lowWater, overflow) {
|
|
65
|
+
this.ctx = ctx;
|
|
66
|
+
this.dimension = ctx.cell(dimension);
|
|
67
|
+
this.highWater = ctx.cell(highWater);
|
|
68
|
+
this.lowWater = ctx.cell(lowWater);
|
|
69
|
+
this.overflow = ctx.cell(overflow);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The algebra-typed conflating relay (Phase 2, in-proc core). The hot head is a
|
|
75
|
+
* cell; depth/is_full/is_empty are demand-driven slots, so an unobserved relay
|
|
76
|
+
* costs N·⊕ and no more (the merge cost law).
|
|
77
|
+
*/
|
|
78
|
+
export class RelayCell {
|
|
79
|
+
/**
|
|
80
|
+
* @param {import("./reactive.js").Context} ctx
|
|
81
|
+
* @param {BackpressurePolicy} policy
|
|
82
|
+
* Throws RelayConfigError.ConflateNotBounding if Conflate is chosen for a
|
|
83
|
+
* non-conflating policy.
|
|
84
|
+
*/
|
|
85
|
+
constructor(ctx, policy, mergePolicy) {
|
|
86
|
+
if (ctx.getCell(policy.overflow) === Overflow.Conflate && !mergePolicy.conflates) {
|
|
87
|
+
throw new Error(RelayConfigError.ConflateNotBounding);
|
|
88
|
+
}
|
|
89
|
+
this.ctx = ctx;
|
|
90
|
+
this.policy = policy;
|
|
91
|
+
this.mergePolicy = mergePolicy;
|
|
92
|
+
// Hot head: current window's coalesced value (null = empty window).
|
|
93
|
+
this._head = ctx.cell(null);
|
|
94
|
+
// Ops merged into the current window since the last drain (the Count bound).
|
|
95
|
+
this._pending = ctx.cell(0);
|
|
96
|
+
this._depth = ctx.computed(() => ctx.getCell(this._pending));
|
|
97
|
+
this._isFull = ctx.computed(
|
|
98
|
+
() => ctx.getCell(this._pending) >= ctx.getCell(policy.highWater),
|
|
99
|
+
);
|
|
100
|
+
this._isEmpty = ctx.computed(() => ctx.getCell(this._head) === null);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether the current overflow choice is legal for the policy. */
|
|
104
|
+
overflowIsLegal() {
|
|
105
|
+
return (
|
|
106
|
+
this.ctx.getCell(this.policy.overflow) !== Overflow.Conflate ||
|
|
107
|
+
this.mergePolicy.conflates
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Demand-driven reader: current window depth (Count). */
|
|
112
|
+
depth() {
|
|
113
|
+
return this.ctx.get(this._depth);
|
|
114
|
+
}
|
|
115
|
+
/** Demand-driven reader: window is at/over high_water. */
|
|
116
|
+
isFull() {
|
|
117
|
+
return this.ctx.get(this._isFull);
|
|
118
|
+
}
|
|
119
|
+
/** Demand-driven reader: window is empty (nothing to drain). */
|
|
120
|
+
isEmpty() {
|
|
121
|
+
return this.ctx.get(this._isEmpty);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The reader slot handles (for wiring into effects/computations). */
|
|
125
|
+
depthSlot() {
|
|
126
|
+
return this._depth;
|
|
127
|
+
}
|
|
128
|
+
isFullSlot() {
|
|
129
|
+
return this._isFull;
|
|
130
|
+
}
|
|
131
|
+
isEmptySlot() {
|
|
132
|
+
return this._isEmpty;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
_readFull() {
|
|
136
|
+
return this.ctx.getCell(this._pending) >= this.ctx.getCell(this.policy.highWater);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_mergeIntoHead(op) {
|
|
140
|
+
const cur = this.ctx.getCell(this._head);
|
|
141
|
+
const next = cur === null ? op : this.mergePolicy.merge(cur, op);
|
|
142
|
+
this.ctx.setCell(this._head, next);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Ingest one op. Applies the reactive overflow policy when the window is at
|
|
147
|
+
* high_water; otherwise merges the op into the hot head under the policy.
|
|
148
|
+
*/
|
|
149
|
+
ingress(op) {
|
|
150
|
+
const wasEmpty = this.ctx.getCell(this._pending) === 0;
|
|
151
|
+
if (this._readFull()) {
|
|
152
|
+
switch (this.ctx.getCell(this.policy.overflow)) {
|
|
153
|
+
case Overflow.Block:
|
|
154
|
+
return IngressOutcome.Blocked;
|
|
155
|
+
case Overflow.DropNewest:
|
|
156
|
+
return IngressOutcome.Dropped;
|
|
157
|
+
case Overflow.DropOldest:
|
|
158
|
+
this.ctx.setCell(this._head, op);
|
|
159
|
+
this.ctx.setCell(this._pending, 1);
|
|
160
|
+
return IngressOutcome.Dropped;
|
|
161
|
+
// Conflate keeps merging; Spill is Phase 3 and, until wired, degrades to
|
|
162
|
+
// Conflate for a bounding policy. Both fall through to the merge below.
|
|
163
|
+
case Overflow.Conflate:
|
|
164
|
+
case Overflow.Spill:
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
this._mergeIntoHead(op);
|
|
169
|
+
this.ctx.setCell(this._pending, this.ctx.getCell(this._pending) + 1);
|
|
170
|
+
return wasEmpty ? IngressOutcome.Accepted : IngressOutcome.Conflated;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Drain the coalesced window: take the hot head's value and reset the window.
|
|
175
|
+
* Returns null for an empty window. relay_converges guarantees the egress fold
|
|
176
|
+
* equals the flat fold of every ingested op, for any drain schedule.
|
|
177
|
+
*/
|
|
178
|
+
drain() {
|
|
179
|
+
const cur = this.ctx.getCell(this._head);
|
|
180
|
+
if (cur !== null) {
|
|
181
|
+
this.ctx.setCell(this._head, null);
|
|
182
|
+
this.ctx.setCell(this._pending, 0);
|
|
183
|
+
}
|
|
184
|
+
return cur;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Peek the current coalesced window without draining. */
|
|
188
|
+
peek() {
|
|
189
|
+
return this.ctx.getCell(this._head);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// -- Phase 3: SpillStore -----------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/** How spilled windows are laid out on the durable tail (analysis §6). */
|
|
196
|
+
export const SpillMode = Object.freeze({
|
|
197
|
+
/** Merge each spilled window into the open page until it fills — minimizes
|
|
198
|
+
* disk (keep-latest / semilattice). One page holds a coalesced run. */
|
|
199
|
+
CompactOnWrite: "CompactOnWrite",
|
|
200
|
+
/** Append each spilled window as its own page — preserves increments for an
|
|
201
|
+
* accumulating (non-idempotent) policy that must not double-count. */
|
|
202
|
+
AppendCompact: "AppendCompact",
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A paged durable tail for a RelayCell (Phase 3, in-memory reference backend).
|
|
207
|
+
* Holds a hot page in RAM plus immutable cold pages, a bounded manifest, an
|
|
208
|
+
* egress cursor, and ack-before-reclaim. Memory is O(hot) + O(manifest).
|
|
209
|
+
*/
|
|
210
|
+
export class SpillStore {
|
|
211
|
+
constructor(mode, pageSize, mergePolicy) {
|
|
212
|
+
this.mode = mode;
|
|
213
|
+
this.pageSize = Math.max(1, pageSize);
|
|
214
|
+
this.mergePolicy = mergePolicy;
|
|
215
|
+
/** @type {{id:number, summary:*, bytes:number}[]} immutable cold pages */
|
|
216
|
+
this._pages = [];
|
|
217
|
+
this._openFill = 0;
|
|
218
|
+
this._nextId = 0;
|
|
219
|
+
this._acked = 0; // pages acked from the front (reclaimable)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Spill one coalesced window summary to the durable tail. AppendCompact always
|
|
224
|
+
* opens a new page; CompactOnWrite merges into the open page until it reaches
|
|
225
|
+
* page_size, then seals it.
|
|
226
|
+
*/
|
|
227
|
+
spill(window, bytes) {
|
|
228
|
+
if (this.mode === SpillMode.AppendCompact) {
|
|
229
|
+
this._pushPage(window, bytes);
|
|
230
|
+
} else {
|
|
231
|
+
if (this._openFill >= this.pageSize || this._pages.length === 0) {
|
|
232
|
+
this._pushPage(window, bytes);
|
|
233
|
+
this._openFill = 1;
|
|
234
|
+
} else {
|
|
235
|
+
const last = this._pages[this._pages.length - 1];
|
|
236
|
+
last.summary = this.mergePolicy.merge(last.summary, window);
|
|
237
|
+
last.bytes += bytes;
|
|
238
|
+
this._openFill += 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_pushPage(summary, bytes) {
|
|
244
|
+
this._pages.push({ id: this._nextId, summary, bytes });
|
|
245
|
+
this._nextId += 1;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The manifest: [id, bytes] for every live page (bounded metadata). */
|
|
249
|
+
manifest() {
|
|
250
|
+
return this._pages.map((p) => [p.id, p.bytes]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Pages the egress has not yet acked (at/after the ack cursor). */
|
|
254
|
+
pendingPages() {
|
|
255
|
+
return this._pages.slice(this._acked);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
pageCount() {
|
|
259
|
+
return this._pages.length;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Ack every page through id (inclusive), advancing the reclaim cursor. */
|
|
263
|
+
ackThrough(id) {
|
|
264
|
+
while (this._acked < this._pages.length && this._pages[this._acked].id <= id) {
|
|
265
|
+
this._acked += 1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Drop acked pages (durable reclaim). Manifest/cursor stay consistent. */
|
|
270
|
+
reclaim() {
|
|
271
|
+
if (this._acked > 0) {
|
|
272
|
+
this._pages.splice(0, this._acked);
|
|
273
|
+
this._acked = 0;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Fold every live cold page (oldest first) into s0. */
|
|
278
|
+
foldPages(s0) {
|
|
279
|
+
return this._pages.reduce((acc, p) => this.mergePolicy.merge(acc, p.summary), s0);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Reconstruction (spill_lossless). Fold the cold tail then the hot head —
|
|
284
|
+
* reproduces the flat fold of every op the relay ever ingested.
|
|
285
|
+
*/
|
|
286
|
+
reconstruct(s0, hot) {
|
|
287
|
+
const cold = this.foldPages(s0);
|
|
288
|
+
return hot === null || hot === undefined ? cold : this.mergePolicy.merge(cold, hot);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Crash replay. After recovery the egress re-delivers every unacked page from
|
|
293
|
+
* the ack cursor into downstream. For an idempotent policy re-applying an
|
|
294
|
+
* already-delivered page is a no-op (spill_replay_idempotent), so at-least-once
|
|
295
|
+
* replay converges to the same downstream state.
|
|
296
|
+
*/
|
|
297
|
+
replayUnacked(downstream) {
|
|
298
|
+
return this.pendingPages().reduce(
|
|
299
|
+
(acc, p) => this.mergePolicy.merge(acc, p.summary),
|
|
300
|
+
downstream,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// -- Phase 4: Transport ------------------------------------------------------
|
|
306
|
+
//
|
|
307
|
+
// Transport abstracts ingress/egress delivery so the mechanism is pluggable. A
|
|
308
|
+
// RelayCell is written once against Transport; the merge algebra — not the
|
|
309
|
+
// transport — guarantees converged state (transport_independent), so transports
|
|
310
|
+
// may differ across bindings and still converge.
|
|
311
|
+
|
|
312
|
+
/** InProc — direct delivery: every buffered op is handed over in one frame. */
|
|
313
|
+
export class InProcTransport {
|
|
314
|
+
constructor() {
|
|
315
|
+
this._buf = [];
|
|
316
|
+
}
|
|
317
|
+
deliver(op) {
|
|
318
|
+
this._buf.push(op);
|
|
319
|
+
}
|
|
320
|
+
poll() {
|
|
321
|
+
const out = this._buf;
|
|
322
|
+
this._buf = [];
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
hasPending() {
|
|
326
|
+
return this._buf.length > 0;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A framed transport — models CrossThread/Ipc/Ws: ops are delivered in bounded
|
|
332
|
+
* frames of at most frameSize (an MTU / batch boundary). Different frameSizes are
|
|
333
|
+
* different framings of the same op stream.
|
|
334
|
+
*/
|
|
335
|
+
export class FramedTransport {
|
|
336
|
+
constructor(frameSize) {
|
|
337
|
+
this._buf = [];
|
|
338
|
+
this.frameSize = Math.max(1, frameSize);
|
|
339
|
+
}
|
|
340
|
+
deliver(op) {
|
|
341
|
+
this._buf.push(op);
|
|
342
|
+
}
|
|
343
|
+
poll() {
|
|
344
|
+
const n = Math.min(this.frameSize, this._buf.length);
|
|
345
|
+
return this._buf.splice(0, n);
|
|
346
|
+
}
|
|
347
|
+
hasPending() {
|
|
348
|
+
return this._buf.length > 0;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// -- Phase 5: Outbox / Inbox roles -------------------------------------------
|
|
353
|
+
//
|
|
354
|
+
// RelayCell is direction-neutral; Outbox and Inbox are role facades (typed
|
|
355
|
+
// constructors with direction-appropriate defaults), not reimplementations. They
|
|
356
|
+
// differ in the backpressure-propagation contract. A network link is
|
|
357
|
+
// Outbox → Transport → Inbox.
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The app → transport send side (analysis §4.7). Backpressures the local
|
|
361
|
+
* producer directly via is_full. Default overflow Conflate (state broadcast).
|
|
362
|
+
*/
|
|
363
|
+
export class Outbox {
|
|
364
|
+
/**
|
|
365
|
+
* @param {import("./reactive.js").Context} ctx
|
|
366
|
+
* Build an outbox bounded by highWater. Optional dimension/overflow (e.g.
|
|
367
|
+
* Spill for a lossless event channel).
|
|
368
|
+
*/
|
|
369
|
+
constructor(ctx, highWater, mergePolicy, opts = {}) {
|
|
370
|
+
const dimension = opts.dimension ?? BoundDim.Count;
|
|
371
|
+
const overflow = opts.overflow ?? Overflow.Conflate;
|
|
372
|
+
const policy = new BackpressurePolicy(
|
|
373
|
+
ctx,
|
|
374
|
+
dimension,
|
|
375
|
+
highWater,
|
|
376
|
+
Math.floor(highWater / 2),
|
|
377
|
+
overflow,
|
|
378
|
+
);
|
|
379
|
+
this._relay = new RelayCell(ctx, policy, mergePolicy);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The local producer sends an op. A Blocked outcome is the producer's
|
|
383
|
+
* backpressure signal — it should await a drain before retrying. */
|
|
384
|
+
send(op) {
|
|
385
|
+
return this._relay.ingress(op);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The transport drains the coalesced window for egress. */
|
|
389
|
+
drain() {
|
|
390
|
+
return this._relay.drain();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The producer-facing backpressure signal (window at/over the watermark). */
|
|
394
|
+
isFull() {
|
|
395
|
+
return this._relay.isFull();
|
|
396
|
+
}
|
|
397
|
+
isFullSlot() {
|
|
398
|
+
return this._relay.isFullSlot();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Access the underlying relay (for wiring extra egress stages). */
|
|
402
|
+
relay() {
|
|
403
|
+
return this._relay;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The transport → app receive side (analysis §4.7). Cannot block the remote
|
|
409
|
+
* directly; backpressure is a credit meter the app replenishes.
|
|
410
|
+
*/
|
|
411
|
+
export class Inbox {
|
|
412
|
+
/**
|
|
413
|
+
* @param {import("./reactive.js").Context} ctx
|
|
414
|
+
* Build an inbox bounded by highWater with a credit budget of maxCredits.
|
|
415
|
+
*/
|
|
416
|
+
constructor(ctx, highWater, maxCredits, mergePolicy, opts = {}) {
|
|
417
|
+
const overflow = opts.overflow ?? Overflow.Conflate;
|
|
418
|
+
const policy = new BackpressurePolicy(
|
|
419
|
+
ctx,
|
|
420
|
+
BoundDim.Count,
|
|
421
|
+
highWater,
|
|
422
|
+
Math.floor(highWater / 2),
|
|
423
|
+
overflow,
|
|
424
|
+
);
|
|
425
|
+
this._relay = new RelayCell(ctx, policy, mergePolicy);
|
|
426
|
+
this._credits = maxCredits;
|
|
427
|
+
this._maxCredits = maxCredits;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Whether the transport may deliver another message (a credit is available).
|
|
431
|
+
* When false, the transport must stop reading → the remote throttles. */
|
|
432
|
+
ready() {
|
|
433
|
+
return this._credits > 0;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Credits currently available to the remote. */
|
|
437
|
+
credits() {
|
|
438
|
+
return this._credits;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The transport delivers a received op. Consumes a credit; the caller MUST
|
|
442
|
+
* have checked ready() (a delivery without credit still applies but drives
|
|
443
|
+
* credits to zero, signalling the remote to stop). */
|
|
444
|
+
receive(op) {
|
|
445
|
+
this._credits = Math.max(0, this._credits - 1);
|
|
446
|
+
return this._relay.ingress(op);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The app consumes the coalesced window and replenishes n credits (up to the
|
|
450
|
+
* budget), re-opening the remote's flow. */
|
|
451
|
+
consume(replenish) {
|
|
452
|
+
const out = this._relay.drain();
|
|
453
|
+
this._credits = Math.min(this._credits + replenish, this._maxCredits);
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// -- Phase 6: extra reactive policies ----------------------------------------
|
|
459
|
+
//
|
|
460
|
+
// Each policy is an optional reactive stage composed onto a relay egress; they
|
|
461
|
+
// only change where/when a relay flushes or which ops survive. Time is a logical
|
|
462
|
+
// clock (a monotone tick) — a binding drives tick/advance from its own runtime
|
|
463
|
+
// timer.
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Case 9 — rate-limited egress (token bucket). A drain is permitted only when a
|
|
467
|
+
* token is available. Refilled refillPerTick tokens per logical tick, capped at
|
|
468
|
+
* capacity.
|
|
469
|
+
*/
|
|
470
|
+
export class RatePolicy {
|
|
471
|
+
constructor(capacity, refillPerTick) {
|
|
472
|
+
this.capacity = capacity;
|
|
473
|
+
this._tokens = capacity;
|
|
474
|
+
this.refillPerTick = refillPerTick;
|
|
475
|
+
}
|
|
476
|
+
tokens() {
|
|
477
|
+
return this._tokens;
|
|
478
|
+
}
|
|
479
|
+
/** Try to consume one token for an egress; returns true if paced through. */
|
|
480
|
+
tryEgress() {
|
|
481
|
+
if (this._tokens > 0) {
|
|
482
|
+
this._tokens -= 1;
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
/** Advance the logical clock, refilling the bucket (saturating at capacity). */
|
|
488
|
+
tick() {
|
|
489
|
+
this._tokens = Math.min(this._tokens + this.refillPerTick, this.capacity);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Case 8 — time-windowed coalescence (debounce/throttle). Flushes when it reaches
|
|
495
|
+
* windowOps ops or on an explicit tick. Because a window is just a flush group,
|
|
496
|
+
* associativity keeps the converged state unchanged (flushGroupingIrrelevant).
|
|
497
|
+
*/
|
|
498
|
+
export class WindowPolicy {
|
|
499
|
+
constructor(windowOps) {
|
|
500
|
+
this.windowOps = Math.max(1, windowOps);
|
|
501
|
+
this._pending = 0;
|
|
502
|
+
}
|
|
503
|
+
/** Record one ingress; returns true when the window is full and should flush. */
|
|
504
|
+
onIngress() {
|
|
505
|
+
this._pending += 1;
|
|
506
|
+
if (this._pending >= this.windowOps) {
|
|
507
|
+
this._pending = 0;
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
/** The debounce/throttle interval elapsed: flush whatever is pending. */
|
|
513
|
+
tick() {
|
|
514
|
+
if (this._pending > 0) {
|
|
515
|
+
this._pending = 0;
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Case 10 — TTL / deadline expiry. Drops elements whose age exceeds ttl against a
|
|
524
|
+
* logical clock. Lossy-by-age (explicit); used to shed cold data.
|
|
525
|
+
*/
|
|
526
|
+
export class ExpiryPolicy {
|
|
527
|
+
constructor(ttl) {
|
|
528
|
+
this.ttl = ttl;
|
|
529
|
+
this._now = 0;
|
|
530
|
+
}
|
|
531
|
+
advance(by) {
|
|
532
|
+
this._now += by;
|
|
533
|
+
}
|
|
534
|
+
now() {
|
|
535
|
+
return this._now;
|
|
536
|
+
}
|
|
537
|
+
/** Whether an element stamped at stampedAt is still live (not expired). */
|
|
538
|
+
isLive(stampedAt) {
|
|
539
|
+
return this._now - stampedAt <= this.ttl;
|
|
540
|
+
}
|
|
541
|
+
/** Retain only the live elements of a [ts, value] batch (drop the aged tail). */
|
|
542
|
+
retainLive(batch) {
|
|
543
|
+
return batch.filter(([ts]) => this.isLive(ts)).map(([, v]) => v);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Case 11 — priority egress. Ingress carries a priority; egress pops the highest
|
|
549
|
+
* priority first (not FIFO), FIFO within equal priority. Reordering, so sound for
|
|
550
|
+
* a commutative merge downstream (reorder_adjacent).
|
|
551
|
+
*/
|
|
552
|
+
export class PriorityStorage {
|
|
553
|
+
constructor() {
|
|
554
|
+
/** @type {{priority:number, seq:number, value:*}[]} */
|
|
555
|
+
this._items = [];
|
|
556
|
+
this._seq = 0;
|
|
557
|
+
}
|
|
558
|
+
push(priority, value) {
|
|
559
|
+
this._items.push({ priority, seq: this._seq, value });
|
|
560
|
+
this._seq += 1;
|
|
561
|
+
}
|
|
562
|
+
/** Pop the highest-priority element (FIFO within equal priority). */
|
|
563
|
+
pop() {
|
|
564
|
+
if (this._items.length === 0) return null;
|
|
565
|
+
let best = 0;
|
|
566
|
+
for (let i = 1; i < this._items.length; i++) {
|
|
567
|
+
const a = this._items[i];
|
|
568
|
+
const b = this._items[best];
|
|
569
|
+
if (a.priority > b.priority || (a.priority === b.priority && a.seq < b.seq)) {
|
|
570
|
+
best = i;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const [out] = this._items.splice(best, 1);
|
|
574
|
+
return out.value;
|
|
575
|
+
}
|
|
576
|
+
get length() {
|
|
577
|
+
return this._items.length;
|
|
578
|
+
}
|
|
579
|
+
isEmpty() {
|
|
580
|
+
return this._items.length === 0;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Case 18 — keyed sharding. N independent relays keyed by K; an op routes to its
|
|
586
|
+
* key's shard. Merging across shards requires a commutative merge. The converged
|
|
587
|
+
* per-key state equals a single relay per key.
|
|
588
|
+
*/
|
|
589
|
+
export class KeyedRelay {
|
|
590
|
+
constructor(ctx, highWater, overflow, mergePolicy) {
|
|
591
|
+
this.ctx = ctx;
|
|
592
|
+
this.highWater = highWater;
|
|
593
|
+
this.overflow = overflow;
|
|
594
|
+
this.mergePolicy = mergePolicy;
|
|
595
|
+
/** @type {Map<*, RelayCell>} */
|
|
596
|
+
this._shards = new Map();
|
|
597
|
+
}
|
|
598
|
+
/** Route op to key's shard, creating the shard on first use. */
|
|
599
|
+
ingress(key, op) {
|
|
600
|
+
let relay = this._shards.get(key);
|
|
601
|
+
if (relay === undefined) {
|
|
602
|
+
const policy = new BackpressurePolicy(
|
|
603
|
+
this.ctx,
|
|
604
|
+
BoundDim.Count,
|
|
605
|
+
this.highWater,
|
|
606
|
+
Math.floor(this.highWater / 2),
|
|
607
|
+
this.overflow,
|
|
608
|
+
);
|
|
609
|
+
relay = new RelayCell(this.ctx, policy, this.mergePolicy);
|
|
610
|
+
this._shards.set(key, relay);
|
|
611
|
+
}
|
|
612
|
+
return relay.ingress(op);
|
|
613
|
+
}
|
|
614
|
+
/** Drain a key's coalesced window. */
|
|
615
|
+
drain(key) {
|
|
616
|
+
const relay = this._shards.get(key);
|
|
617
|
+
return relay === undefined ? null : relay.drain();
|
|
618
|
+
}
|
|
619
|
+
keys() {
|
|
620
|
+
return this._shards.keys();
|
|
621
|
+
}
|
|
622
|
+
}
|