@lazily-hub/lazily-js 0.18.0 → 0.19.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 +13 -3
- package/package.json +34 -2
- package/src/coordination.d.ts +81 -0
- package/src/coordination.js +283 -0
- package/src/membership.d.ts +52 -0
- package/src/membership.js +178 -0
- package/src/presence.d.ts +52 -0
- package/src/presence.js +154 -0
- package/src/rateshape.d.ts +79 -0
- package/src/rateshape.js +238 -0
- package/src/resilience.d.ts +67 -0
- package/src/resilience.js +231 -0
- package/src/service.d.ts +61 -0
- package/src/service.js +234 -0
- package/src/temporal.d.ts +85 -0
- package/src/temporal.js +226 -0
- package/src/windowing.d.ts +52 -0
- package/src/windowing.js +185 -0
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ notes and platform carve-outs lives in
|
|
|
38
38
|
<!-- coverage-table:start -->
|
|
39
39
|
| Feature | Rust | Python | Kotlin | JS | Dart | Zig | Go | C++ |
|
|
40
40
|
| --------- | :----: | :------: | :------: | :--: | :----: | :---: | :--: | :---: |
|
|
41
|
-
| Reactive graph — `Cell` / `Slot` / `Signal`
|
|
41
|
+
| Reactive graph — core `Cell` / `Slot` / `Effect` (+ derived `Signal` = `Slot.eager`) / memo / batch | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
42
42
|
| Keyed-map materialization (`SlotMap`) — mint-on-access derived slots: transparency + deferral (`#lzmatmode`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
43
43
|
| Thread-safe keyed map (`ThreadSafeSlotMap`) — `Send + Sync` + materialization confluence (`#lzmatmode`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
44
44
|
| Async keyed map (`AsyncSlotMap`) — eventual transparency (`#lzmatmode`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
@@ -78,6 +78,14 @@ notes and platform carve-outs lives in
|
|
|
78
78
|
| Permission boundary (`PeerPermissions` / `RemoteOp`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
79
79
|
| Capability negotiation (`SessionHandshake`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
80
80
|
| Instrumentation / benchmarks | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
81
|
+
| Temporal sources — `TimerCell` / `IntervalCell` / `CronCell` / `DeadlineCell` over a logical clock (`#lztime`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
82
|
+
| Rate-shaping operators — `DebounceCell` / `ThrottleCell` / `SampleCell` / `ProbabilisticSampleCell` (`#lzrateshape`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
83
|
+
| Membership + failure detection — `MembershipCell` (SWIM + Phi-accrual) / `PeerSet` / `PeerChangeEvent` (`#lzmemb`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
84
|
+
| Distributed coordination — `LeaseCell` / `LeaderCell` / `LockCell` / `SemaphoreCell` / `BarrierCell`+`QuorumCell` (`#lzcoord`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
85
|
+
| Presence + ephemeral plane — `PresenceCell` / `AwarenessCell` / `EphemeralCell` + `Ephemeral`/`Durable` markers (`#lzpresence`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
86
|
+
| Stream windowing — `TumblingWindow` / `SlidingWindow` / `SessionWindow` over the merge algebra (`#lzwindow`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
87
|
+
| Fault tolerance — `CircuitBreakerCell` / `RetryPolicyCell` / `BulkheadCell` / `TimeoutCell` (`#lzresilience`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
88
|
+
| Embedded-service plane — `HealthCell` / `ReadinessCell` / `DiscoveryCell` / `ServiceRegistry` (`#lzservice`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
81
89
|
<!-- coverage-table:end -->
|
|
82
90
|
|
|
83
91
|
Two JS ✅ marks are backed by runtime-specific mechanisms while keeping the core isomorphic:
|
|
@@ -119,10 +127,12 @@ JavaScript. The family is:
|
|
|
119
127
|
|
|
120
128
|
- **Slot** - lazy, memoized derived value;
|
|
121
129
|
- **Cell** - mutable source value;
|
|
122
|
-
- **Signal** - eager derived value that re-materializes as soon as a dependency
|
|
123
|
-
invalidates;
|
|
124
130
|
- **Effect** - side-effecting observer with cleanup.
|
|
125
131
|
|
|
132
|
+
The core primitives are **Cell** / **Slot** / **Effect**. **`Signal` is a
|
|
133
|
+
derived construct, not a core primitive** — `Signal ≡ Slot.eager`, a memo Slot
|
|
134
|
+
plus a puller Effect that re-materializes as soon as a dependency invalidates.
|
|
135
|
+
|
|
126
136
|
Dependencies are discovered dynamically while a slot/effect/signal computes.
|
|
127
137
|
Invalidation is pull-based and glitch-free; `memo`/`Signal` use equality guards
|
|
128
138
|
to suppress downstream work when a recompute produces the same value. `batch`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazily-hub/lazily-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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": {
|
|
@@ -77,6 +77,38 @@
|
|
|
77
77
|
"types": "./src/relay.d.ts",
|
|
78
78
|
"default": "./src/relay.js"
|
|
79
79
|
},
|
|
80
|
+
"./temporal": {
|
|
81
|
+
"types": "./src/temporal.d.ts",
|
|
82
|
+
"default": "./src/temporal.js"
|
|
83
|
+
},
|
|
84
|
+
"./rateshape": {
|
|
85
|
+
"types": "./src/rateshape.d.ts",
|
|
86
|
+
"default": "./src/rateshape.js"
|
|
87
|
+
},
|
|
88
|
+
"./membership": {
|
|
89
|
+
"types": "./src/membership.d.ts",
|
|
90
|
+
"default": "./src/membership.js"
|
|
91
|
+
},
|
|
92
|
+
"./coordination": {
|
|
93
|
+
"types": "./src/coordination.d.ts",
|
|
94
|
+
"default": "./src/coordination.js"
|
|
95
|
+
},
|
|
96
|
+
"./presence": {
|
|
97
|
+
"types": "./src/presence.d.ts",
|
|
98
|
+
"default": "./src/presence.js"
|
|
99
|
+
},
|
|
100
|
+
"./windowing": {
|
|
101
|
+
"types": "./src/windowing.d.ts",
|
|
102
|
+
"default": "./src/windowing.js"
|
|
103
|
+
},
|
|
104
|
+
"./resilience": {
|
|
105
|
+
"types": "./src/resilience.d.ts",
|
|
106
|
+
"default": "./src/resilience.js"
|
|
107
|
+
},
|
|
108
|
+
"./service": {
|
|
109
|
+
"types": "./src/service.d.ts",
|
|
110
|
+
"default": "./src/service.js"
|
|
111
|
+
},
|
|
80
112
|
"./sem-tree": {
|
|
81
113
|
"types": "./src/sem-tree.d.ts",
|
|
82
114
|
"default": "./src/sem-tree.js"
|
|
@@ -116,7 +148,7 @@
|
|
|
116
148
|
}
|
|
117
149
|
},
|
|
118
150
|
"scripts": {
|
|
119
|
-
"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/indexeddb-outbox.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",
|
|
151
|
+
"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/rateshape.js && node --check src/membership.js && node --check src/coordination.js && node --check src/presence.js && node --check src/windowing.js && node --check src/resilience.js && node --check src/service.js && node --check src/temporal.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/indexeddb-outbox.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",
|
|
120
152
|
"test:formal": "node scripts/formal-check.mjs",
|
|
121
153
|
"bench": "node bench/context.bench.mjs",
|
|
122
154
|
"bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Distributed coordination (#lzcoord) — the JS port.
|
|
2
|
+
// See coordination.js and `lazily-spec/docs/coordination.md`.
|
|
3
|
+
|
|
4
|
+
import type { Context, CellHandle } from "./reactive.js";
|
|
5
|
+
|
|
6
|
+
export class LeaseCore<P = unknown> {
|
|
7
|
+
fence: number;
|
|
8
|
+
isHeld(now: number): boolean;
|
|
9
|
+
holder(now: number): P | null;
|
|
10
|
+
acquire(peer: P, now: number, ttl: number): number | null;
|
|
11
|
+
renew(peer: P, now: number, ttl: number): boolean;
|
|
12
|
+
release(peer: P): void;
|
|
13
|
+
tick(now: number): boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class LeaseCell<P = unknown> {
|
|
17
|
+
constructor(ctx: Context);
|
|
18
|
+
readonly holderCell: CellHandle<P | null>;
|
|
19
|
+
acquire(peer: P, now: number, ttl: number): number | null;
|
|
20
|
+
renew(peer: P, now: number, ttl: number): boolean;
|
|
21
|
+
release(peer: P, now: number): void;
|
|
22
|
+
tick(now: number): boolean;
|
|
23
|
+
holder(now: number): P | null;
|
|
24
|
+
isHeld(now: number): boolean;
|
|
25
|
+
fence(): number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type LeaderRoleLabel = "Leader" | "Follower" | "Candidate";
|
|
29
|
+
export const LeaderRole: Readonly<{ Leader: "Leader"; Follower: "Follower"; Candidate: "Candidate" }>;
|
|
30
|
+
|
|
31
|
+
export class LeaderCell<P = unknown> {
|
|
32
|
+
constructor(ctx: Context, me: P);
|
|
33
|
+
readonly currentLeaderCell: CellHandle<P | null>;
|
|
34
|
+
campaign(now: number, ttl: number): LeaderRoleLabel;
|
|
35
|
+
contend(peer: P, now: number, ttl: number): LeaderRoleLabel;
|
|
36
|
+
tick(now: number): LeaderRoleLabel;
|
|
37
|
+
currentLeader(now: number): P | null;
|
|
38
|
+
role(now: number): LeaderRoleLabel;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class LockCell<P = unknown> {
|
|
42
|
+
constructor(ctx: Context);
|
|
43
|
+
readonly isLockedCell: CellHandle<boolean>;
|
|
44
|
+
acquire(peer: P, now: number, ttl: number): number | null;
|
|
45
|
+
release(peer: P, now: number): void;
|
|
46
|
+
tick(now: number): boolean;
|
|
47
|
+
validate(fence: number): boolean;
|
|
48
|
+
isLocked(now: number): boolean;
|
|
49
|
+
fence(): number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class SemaphoreCore {
|
|
53
|
+
constructor(capacity: number);
|
|
54
|
+
available(): number;
|
|
55
|
+
acquire(): boolean;
|
|
56
|
+
release(): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class SemaphoreCell {
|
|
60
|
+
constructor(ctx: Context, capacity: number);
|
|
61
|
+
readonly permitsAvailableCell: CellHandle<number>;
|
|
62
|
+
acquire(): boolean;
|
|
63
|
+
release(): void;
|
|
64
|
+
permitsAvailable(): number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class BarrierCore<P = unknown> {
|
|
68
|
+
constructor(required: number);
|
|
69
|
+
arrive(peer: P): boolean;
|
|
70
|
+
count(): number;
|
|
71
|
+
isOpen(): boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class BarrierCell<P = unknown> {
|
|
75
|
+
constructor(ctx: Context, required: number);
|
|
76
|
+
static quorum<P = unknown>(ctx: Context, total: number): BarrierCell<P>;
|
|
77
|
+
readonly isOpenCell: CellHandle<boolean>;
|
|
78
|
+
arrive(peer: P): boolean;
|
|
79
|
+
count(): number;
|
|
80
|
+
isOpen(): boolean;
|
|
81
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Distributed coordination (#lzcoord) — the JS port.
|
|
2
|
+
//
|
|
3
|
+
// See `lazily-spec/docs/coordination.md` and the formal model
|
|
4
|
+
// `lazily-formal/LazilyFormal/Coordination.lean`. Lease / leader / lock /
|
|
5
|
+
// semaphore / barrier + quorum primitives, each a pure compute core split from a
|
|
6
|
+
// reactive cell projecting the salient reader. Time is the logical clock. A
|
|
7
|
+
// holder of `null` means "no holder".
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Lease + fencing token
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Single-writer lease authority with a monotone fencing token. */
|
|
14
|
+
export class LeaseCore {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.holderPeer = null;
|
|
17
|
+
this.expiry = 0;
|
|
18
|
+
this.fence = 0;
|
|
19
|
+
}
|
|
20
|
+
#isExpired(now) {
|
|
21
|
+
return this.holderPeer !== null && now >= this.expiry;
|
|
22
|
+
}
|
|
23
|
+
isHeld(now) {
|
|
24
|
+
return this.holderPeer !== null && !this.#isExpired(now);
|
|
25
|
+
}
|
|
26
|
+
holder(now) {
|
|
27
|
+
return this.isHeld(now) ? this.holderPeer : null;
|
|
28
|
+
}
|
|
29
|
+
acquire(peer, now, ttl) {
|
|
30
|
+
if (this.holderPeer === null || this.#isExpired(now)) {
|
|
31
|
+
this.fence += 1;
|
|
32
|
+
this.holderPeer = peer;
|
|
33
|
+
this.expiry = now + ttl;
|
|
34
|
+
return this.fence;
|
|
35
|
+
}
|
|
36
|
+
if (this.holderPeer === peer) {
|
|
37
|
+
this.expiry = now + ttl; // renew keeps fence
|
|
38
|
+
return this.fence;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
renew(peer, now, ttl) {
|
|
43
|
+
if (this.isHeld(now) && this.holderPeer === peer) {
|
|
44
|
+
this.expiry = now + ttl;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
release(peer) {
|
|
50
|
+
if (this.holderPeer === peer) this.holderPeer = null;
|
|
51
|
+
}
|
|
52
|
+
tick(now) {
|
|
53
|
+
if (this.#isExpired(now)) {
|
|
54
|
+
this.holderPeer = null;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reactive lease: projects the holder onto a cell. */
|
|
62
|
+
export class LeaseCell {
|
|
63
|
+
constructor(ctx) {
|
|
64
|
+
this.ctx = ctx;
|
|
65
|
+
this.core = new LeaseCore();
|
|
66
|
+
this.holderCell = ctx.cell(null);
|
|
67
|
+
}
|
|
68
|
+
#refresh(now) {
|
|
69
|
+
this.ctx.setCell(this.holderCell, this.core.holder(now));
|
|
70
|
+
}
|
|
71
|
+
acquire(peer, now, ttl) {
|
|
72
|
+
const r = this.core.acquire(peer, now, ttl);
|
|
73
|
+
this.#refresh(now);
|
|
74
|
+
return r;
|
|
75
|
+
}
|
|
76
|
+
renew(peer, now, ttl) {
|
|
77
|
+
const r = this.core.renew(peer, now, ttl);
|
|
78
|
+
this.#refresh(now);
|
|
79
|
+
return r;
|
|
80
|
+
}
|
|
81
|
+
release(peer, now) {
|
|
82
|
+
this.core.release(peer);
|
|
83
|
+
this.#refresh(now);
|
|
84
|
+
}
|
|
85
|
+
tick(now) {
|
|
86
|
+
const r = this.core.tick(now);
|
|
87
|
+
this.#refresh(now);
|
|
88
|
+
return r;
|
|
89
|
+
}
|
|
90
|
+
holder(now) {
|
|
91
|
+
return this.core.holder(now);
|
|
92
|
+
}
|
|
93
|
+
isHeld(now) {
|
|
94
|
+
return this.core.isHeld(now);
|
|
95
|
+
}
|
|
96
|
+
fence() {
|
|
97
|
+
return this.core.fence;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Leader / follower / candidate
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export const LeaderRole = Object.freeze({
|
|
106
|
+
Leader: "Leader",
|
|
107
|
+
Follower: "Follower",
|
|
108
|
+
Candidate: "Candidate",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/** Reactive leadership over a lease from node `me`'s perspective. */
|
|
112
|
+
export class LeaderCell {
|
|
113
|
+
constructor(ctx, me) {
|
|
114
|
+
this.ctx = ctx;
|
|
115
|
+
this.me = me;
|
|
116
|
+
this.core = new LeaseCore();
|
|
117
|
+
this.currentLeaderCell = ctx.cell(null);
|
|
118
|
+
}
|
|
119
|
+
#refresh(now) {
|
|
120
|
+
this.ctx.setCell(this.currentLeaderCell, this.core.holder(now));
|
|
121
|
+
}
|
|
122
|
+
campaign(now, ttl) {
|
|
123
|
+
this.core.acquire(this.me, now, ttl);
|
|
124
|
+
this.#refresh(now);
|
|
125
|
+
return this.role(now);
|
|
126
|
+
}
|
|
127
|
+
contend(peer, now, ttl) {
|
|
128
|
+
this.core.acquire(peer, now, ttl);
|
|
129
|
+
this.#refresh(now);
|
|
130
|
+
return this.role(now);
|
|
131
|
+
}
|
|
132
|
+
tick(now) {
|
|
133
|
+
this.core.tick(now);
|
|
134
|
+
this.#refresh(now);
|
|
135
|
+
return this.role(now);
|
|
136
|
+
}
|
|
137
|
+
currentLeader(now) {
|
|
138
|
+
return this.core.holder(now);
|
|
139
|
+
}
|
|
140
|
+
role(now) {
|
|
141
|
+
const h = this.core.holder(now);
|
|
142
|
+
if (h === null) return LeaderRole.Candidate;
|
|
143
|
+
return h === this.me ? LeaderRole.Leader : LeaderRole.Follower;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Distributed lock + fencing
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/** Reactive distributed mutex over a lease + fencing token. */
|
|
152
|
+
export class LockCell {
|
|
153
|
+
constructor(ctx) {
|
|
154
|
+
this.ctx = ctx;
|
|
155
|
+
this.core = new LeaseCore();
|
|
156
|
+
this.isLockedCell = ctx.cell(false);
|
|
157
|
+
}
|
|
158
|
+
#refresh(now) {
|
|
159
|
+
this.ctx.setCell(this.isLockedCell, this.core.isHeld(now));
|
|
160
|
+
}
|
|
161
|
+
acquire(peer, now, ttl) {
|
|
162
|
+
const r = this.core.acquire(peer, now, ttl);
|
|
163
|
+
this.#refresh(now);
|
|
164
|
+
return r;
|
|
165
|
+
}
|
|
166
|
+
release(peer, now) {
|
|
167
|
+
this.core.release(peer);
|
|
168
|
+
this.#refresh(now);
|
|
169
|
+
}
|
|
170
|
+
tick(now) {
|
|
171
|
+
const r = this.core.tick(now);
|
|
172
|
+
this.#refresh(now);
|
|
173
|
+
return r;
|
|
174
|
+
}
|
|
175
|
+
/** Whether `fence` is the current (non-stale) fencing token. */
|
|
176
|
+
validate(fence) {
|
|
177
|
+
return this.core.fence === fence;
|
|
178
|
+
}
|
|
179
|
+
isLocked(now) {
|
|
180
|
+
return this.core.isHeld(now);
|
|
181
|
+
}
|
|
182
|
+
fence() {
|
|
183
|
+
return this.core.fence;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Semaphore
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** Bounded permit pool compute core. */
|
|
192
|
+
export class SemaphoreCore {
|
|
193
|
+
constructor(capacity) {
|
|
194
|
+
this.capacity = capacity;
|
|
195
|
+
this.acquired = 0;
|
|
196
|
+
}
|
|
197
|
+
available() {
|
|
198
|
+
return this.capacity - this.acquired;
|
|
199
|
+
}
|
|
200
|
+
acquire() {
|
|
201
|
+
if (this.acquired < this.capacity) {
|
|
202
|
+
this.acquired += 1;
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
release() {
|
|
208
|
+
if (this.acquired > 0) this.acquired -= 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Reactive semaphore: projects permitsAvailable onto a cell. */
|
|
213
|
+
export class SemaphoreCell {
|
|
214
|
+
constructor(ctx, capacity) {
|
|
215
|
+
this.ctx = ctx;
|
|
216
|
+
this.core = new SemaphoreCore(capacity);
|
|
217
|
+
this.permitsAvailableCell = ctx.cell(capacity);
|
|
218
|
+
}
|
|
219
|
+
#refresh() {
|
|
220
|
+
this.ctx.setCell(this.permitsAvailableCell, this.core.available());
|
|
221
|
+
}
|
|
222
|
+
acquire() {
|
|
223
|
+
const r = this.core.acquire();
|
|
224
|
+
this.#refresh();
|
|
225
|
+
return r;
|
|
226
|
+
}
|
|
227
|
+
release() {
|
|
228
|
+
this.core.release();
|
|
229
|
+
this.#refresh();
|
|
230
|
+
}
|
|
231
|
+
permitsAvailable() {
|
|
232
|
+
return this.ctx.getCell(this.permitsAvailableCell);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Barrier / quorum
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/** Wait-for-N gate over distinct arriving peers. */
|
|
241
|
+
export class BarrierCore {
|
|
242
|
+
constructor(required) {
|
|
243
|
+
this.required = required;
|
|
244
|
+
this.arrived = new Set();
|
|
245
|
+
}
|
|
246
|
+
arrive(peer) {
|
|
247
|
+
this.arrived.add(peer);
|
|
248
|
+
return this.isOpen();
|
|
249
|
+
}
|
|
250
|
+
count() {
|
|
251
|
+
return this.arrived.size;
|
|
252
|
+
}
|
|
253
|
+
isOpen() {
|
|
254
|
+
return this.count() >= this.required;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Reactive wait-for-N gate. A quorum is a barrier with required = total/2 + 1. */
|
|
259
|
+
export class BarrierCell {
|
|
260
|
+
constructor(ctx, required) {
|
|
261
|
+
this.ctx = ctx;
|
|
262
|
+
this.core = new BarrierCore(required);
|
|
263
|
+
this.isOpenCell = ctx.cell(this.core.isOpen());
|
|
264
|
+
}
|
|
265
|
+
/** A quorum gate: opens at strict majority of `total`. */
|
|
266
|
+
static quorum(ctx, total) {
|
|
267
|
+
return new BarrierCell(ctx, Math.floor(total / 2) + 1);
|
|
268
|
+
}
|
|
269
|
+
#refresh() {
|
|
270
|
+
this.ctx.setCell(this.isOpenCell, this.core.isOpen());
|
|
271
|
+
}
|
|
272
|
+
arrive(peer) {
|
|
273
|
+
const r = this.core.arrive(peer);
|
|
274
|
+
this.#refresh();
|
|
275
|
+
return r;
|
|
276
|
+
}
|
|
277
|
+
count() {
|
|
278
|
+
return this.core.count();
|
|
279
|
+
}
|
|
280
|
+
isOpen() {
|
|
281
|
+
return this.ctx.getCell(this.isOpenCell);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Membership + failure detection (#lzmemb) — the JS port.
|
|
2
|
+
// See membership.js and `lazily-spec/docs/membership.md`.
|
|
3
|
+
|
|
4
|
+
import type { Context, CellHandle } from "./reactive.js";
|
|
5
|
+
|
|
6
|
+
export type PeerStateLabel = "Alive" | "Suspect" | "Dead" | "Left";
|
|
7
|
+
export const PeerState: Readonly<{
|
|
8
|
+
Alive: "Alive";
|
|
9
|
+
Suspect: "Suspect";
|
|
10
|
+
Dead: "Dead";
|
|
11
|
+
Left: "Left";
|
|
12
|
+
}>;
|
|
13
|
+
|
|
14
|
+
export interface MembershipConfig {
|
|
15
|
+
phiThreshold: number;
|
|
16
|
+
suspectTimeout: number;
|
|
17
|
+
maxSamples: number;
|
|
18
|
+
minStd: number;
|
|
19
|
+
}
|
|
20
|
+
export const defaultMembershipConfig: Readonly<MembershipConfig>;
|
|
21
|
+
|
|
22
|
+
export type PeerChangeEvent<P> =
|
|
23
|
+
| { type: "Joined"; peer: P }
|
|
24
|
+
| { type: "Left"; peer: P }
|
|
25
|
+
| { type: "StateChanged"; peer: P; from: PeerStateLabel; to: PeerStateLabel };
|
|
26
|
+
|
|
27
|
+
export class PhiAccrual {
|
|
28
|
+
constructor(maxSamples: number, minStd: number);
|
|
29
|
+
heartbeat(now: number): void;
|
|
30
|
+
phi(now: number): number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class MembershipCore<P = unknown> {
|
|
34
|
+
constructor(config?: Partial<MembershipConfig>);
|
|
35
|
+
aliveSet(): P[];
|
|
36
|
+
state(peer: P): PeerStateLabel | null;
|
|
37
|
+
join(peer: P, now: number): PeerChangeEvent<P>[];
|
|
38
|
+
heartbeat(peer: P, now: number): PeerChangeEvent<P>[];
|
|
39
|
+
leave(peer: P, now: number): PeerChangeEvent<P>[];
|
|
40
|
+
tick(now: number): PeerChangeEvent<P>[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class MembershipCell<P = unknown> {
|
|
44
|
+
constructor(ctx: Context, config?: Partial<MembershipConfig>);
|
|
45
|
+
readonly peerSetCell: CellHandle<P[]>;
|
|
46
|
+
join(peer: P, now: number): PeerChangeEvent<P>[];
|
|
47
|
+
heartbeat(peer: P, now: number): PeerChangeEvent<P>[];
|
|
48
|
+
leave(peer: P, now: number): PeerChangeEvent<P>[];
|
|
49
|
+
tick(now: number): PeerChangeEvent<P>[];
|
|
50
|
+
peerSet(): P[];
|
|
51
|
+
state(peer: P): PeerStateLabel | null;
|
|
52
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Membership + failure detection (#lzmemb) — the JS port.
|
|
2
|
+
//
|
|
3
|
+
// See `lazily-spec/docs/membership.md` and the formal model
|
|
4
|
+
// `lazily-formal/LazilyFormal/Membership.lean`. A MembershipCell is a reactive
|
|
5
|
+
// view of the live peer set backed by SWIM-style heartbeats + a Phi-accrual
|
|
6
|
+
// failure detector; the derived peerSet is the Alive peers. The pure core
|
|
7
|
+
// (MembershipCore + PhiAccrual) is the phi math + SWIM state machine, split from
|
|
8
|
+
// the reactive cell projecting the alive set onto a Context cell.
|
|
9
|
+
|
|
10
|
+
/** Per-peer liveness state (SWIM). */
|
|
11
|
+
export const PeerState = Object.freeze({
|
|
12
|
+
Alive: "Alive",
|
|
13
|
+
Suspect: "Suspect",
|
|
14
|
+
Dead: "Dead",
|
|
15
|
+
Left: "Left",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** Default detector + state-machine tunables. */
|
|
19
|
+
export const defaultMembershipConfig = Object.freeze({
|
|
20
|
+
phiThreshold: 8.0,
|
|
21
|
+
suspectTimeout: 5,
|
|
22
|
+
maxSamples: 100,
|
|
23
|
+
minStd: 0.1,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Phi-accrual failure detector; `phi` uses the bit-portable Akka logistic
|
|
27
|
+
* approximation of the normal CDF so every binding agrees. */
|
|
28
|
+
export class PhiAccrual {
|
|
29
|
+
constructor(maxSamples, minStd) {
|
|
30
|
+
this.maxSamples = Math.max(1, maxSamples);
|
|
31
|
+
this.minStd = minStd;
|
|
32
|
+
this.window = [];
|
|
33
|
+
this.lastHeartbeat = null;
|
|
34
|
+
}
|
|
35
|
+
heartbeat(now) {
|
|
36
|
+
if (this.lastHeartbeat !== null) {
|
|
37
|
+
this.window.push(now - this.lastHeartbeat);
|
|
38
|
+
while (this.window.length > this.maxSamples) this.window.shift();
|
|
39
|
+
}
|
|
40
|
+
this.lastHeartbeat = now;
|
|
41
|
+
}
|
|
42
|
+
#mean() {
|
|
43
|
+
return this.window.reduce((a, b) => a + b, 0) / this.window.length;
|
|
44
|
+
}
|
|
45
|
+
#std(mean) {
|
|
46
|
+
const v = this.window.reduce((a, x) => a + (x - mean) * (x - mean), 0) / this.window.length;
|
|
47
|
+
return Math.max(Math.sqrt(v), this.minStd);
|
|
48
|
+
}
|
|
49
|
+
phi(now) {
|
|
50
|
+
if (this.lastHeartbeat === null || this.window.length === 0) return 0.0;
|
|
51
|
+
const elapsed = now - this.lastHeartbeat;
|
|
52
|
+
const mean = this.#mean();
|
|
53
|
+
const std = this.#std(mean);
|
|
54
|
+
const y = (elapsed - mean) / std;
|
|
55
|
+
const e = Math.exp(-y * (1.5976 + 0.070566 * y * y));
|
|
56
|
+
return elapsed > mean ? -Math.log10(e / (1 + e)) : -Math.log10(1 - 1 / (1 + e));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The pure membership compute core: the SWIM state machine over a keyed peer
|
|
61
|
+
* map, driven by heartbeats and a logical clock. Emits PeerChangeEvents:
|
|
62
|
+
* `{ type: "Joined"|"Left"|"StateChanged", peer, from?, to? }`. */
|
|
63
|
+
export class MembershipCore {
|
|
64
|
+
constructor(config = defaultMembershipConfig) {
|
|
65
|
+
this.config = { ...defaultMembershipConfig, ...config };
|
|
66
|
+
this.peers = new Map(); // peer -> { state, detector, suspectSince }
|
|
67
|
+
}
|
|
68
|
+
#newDetector() {
|
|
69
|
+
return new PhiAccrual(this.config.maxSamples, this.config.minStd);
|
|
70
|
+
}
|
|
71
|
+
/** The alive peer set, sorted ascending (matches the spec's BTreeSet). */
|
|
72
|
+
aliveSet() {
|
|
73
|
+
return [...this.peers.entries()]
|
|
74
|
+
.filter(([, r]) => r.state === PeerState.Alive)
|
|
75
|
+
.map(([p]) => p)
|
|
76
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
77
|
+
}
|
|
78
|
+
state(peer) {
|
|
79
|
+
return this.peers.get(peer)?.state ?? null;
|
|
80
|
+
}
|
|
81
|
+
join(peer, now) {
|
|
82
|
+
const detector = this.#newDetector();
|
|
83
|
+
detector.heartbeat(now);
|
|
84
|
+
const prev = this.peers.get(peer)?.state ?? null;
|
|
85
|
+
this.peers.set(peer, { state: PeerState.Alive, detector, suspectSince: null });
|
|
86
|
+
if (prev === null) return [{ type: "Joined", peer }];
|
|
87
|
+
if (prev === PeerState.Alive) return [];
|
|
88
|
+
return [{ type: "StateChanged", peer, from: prev, to: PeerState.Alive }];
|
|
89
|
+
}
|
|
90
|
+
heartbeat(peer, now) {
|
|
91
|
+
const record = this.peers.get(peer);
|
|
92
|
+
if (record === undefined) return this.join(peer, now);
|
|
93
|
+
record.detector.heartbeat(now);
|
|
94
|
+
const from = record.state;
|
|
95
|
+
if (from !== PeerState.Alive && from !== PeerState.Left) {
|
|
96
|
+
record.state = PeerState.Alive;
|
|
97
|
+
record.suspectSince = null;
|
|
98
|
+
return [{ type: "StateChanged", peer, from, to: PeerState.Alive }];
|
|
99
|
+
}
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
leave(peer, _now) {
|
|
103
|
+
const record = this.peers.get(peer);
|
|
104
|
+
if (record === undefined || record.state === PeerState.Left) return [];
|
|
105
|
+
record.state = PeerState.Left;
|
|
106
|
+
record.suspectSince = null;
|
|
107
|
+
return [{ type: "Left", peer }];
|
|
108
|
+
}
|
|
109
|
+
tick(now) {
|
|
110
|
+
const events = [];
|
|
111
|
+
for (const [peer, record] of this.peers) {
|
|
112
|
+
if (record.state === PeerState.Alive) {
|
|
113
|
+
if (record.detector.phi(now) > this.config.phiThreshold) {
|
|
114
|
+
record.state = PeerState.Suspect;
|
|
115
|
+
record.suspectSince = now;
|
|
116
|
+
events.push({ type: "StateChanged", peer, from: PeerState.Alive, to: PeerState.Suspect });
|
|
117
|
+
}
|
|
118
|
+
} else if (record.state === PeerState.Suspect) {
|
|
119
|
+
if (record.suspectSince !== null && now - record.suspectSince >= this.config.suspectTimeout) {
|
|
120
|
+
record.state = PeerState.Dead;
|
|
121
|
+
events.push({ type: "StateChanged", peer, from: PeerState.Suspect, to: PeerState.Dead });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return events;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Compare two alive-set arrays for equality (used by the reactive cell). */
|
|
130
|
+
function setEquals(a, b) {
|
|
131
|
+
if (a.length !== b.length) return false;
|
|
132
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Reactive membership: drives a MembershipCore and projects the alive set onto
|
|
137
|
+
* a Context cell so peerSet invalidates only on a set change. */
|
|
138
|
+
export class MembershipCell {
|
|
139
|
+
constructor(ctx, config = defaultMembershipConfig) {
|
|
140
|
+
this.ctx = ctx;
|
|
141
|
+
this.core = new MembershipCore(config);
|
|
142
|
+
this.peerSetCell = ctx.cell([]);
|
|
143
|
+
}
|
|
144
|
+
#refresh() {
|
|
145
|
+
const next = this.core.aliveSet();
|
|
146
|
+
// Only write when the set changed, so the reader's PartialEq guard holds even
|
|
147
|
+
// though the array identity differs each call.
|
|
148
|
+
if (!setEquals(this.ctx.getCell(this.peerSetCell), next)) {
|
|
149
|
+
this.ctx.setCell(this.peerSetCell, next);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
join(peer, now) {
|
|
153
|
+
const ev = this.core.join(peer, now);
|
|
154
|
+
this.#refresh();
|
|
155
|
+
return ev;
|
|
156
|
+
}
|
|
157
|
+
heartbeat(peer, now) {
|
|
158
|
+
const ev = this.core.heartbeat(peer, now);
|
|
159
|
+
this.#refresh();
|
|
160
|
+
return ev;
|
|
161
|
+
}
|
|
162
|
+
leave(peer, now) {
|
|
163
|
+
const ev = this.core.leave(peer, now);
|
|
164
|
+
this.#refresh();
|
|
165
|
+
return ev;
|
|
166
|
+
}
|
|
167
|
+
tick(now) {
|
|
168
|
+
const ev = this.core.tick(now);
|
|
169
|
+
this.#refresh();
|
|
170
|
+
return ev;
|
|
171
|
+
}
|
|
172
|
+
peerSet() {
|
|
173
|
+
return this.ctx.getCell(this.peerSetCell);
|
|
174
|
+
}
|
|
175
|
+
state(peer) {
|
|
176
|
+
return this.core.state(peer);
|
|
177
|
+
}
|
|
178
|
+
}
|