@lazily-hub/lazily-js 0.16.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 +53 -6
- package/package.json +40 -2
- package/src/coordination.d.ts +81 -0
- package/src/coordination.js +283 -0
- package/src/index.d.ts +25 -5
- package/src/index.js +68 -11
- package/src/indexeddb-outbox.d.ts +18 -0
- package/src/indexeddb-outbox.js +124 -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/queue.d.ts +97 -1
- package/src/queue.js +393 -16
- 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/text-crdt.d.ts +21 -1
- package/src/text-crdt.js +10 -0
- package/src/windowing.d.ts +52 -0
- package/src/windowing.js +185 -0
package/README.md
CHANGED
|
@@ -33,12 +33,12 @@ the FFI projection transport is used.
|
|
|
33
33
|
The full `lazily` capability set across every binding. Legend: ✅ shipped ·
|
|
34
34
|
`~` partial · `—` absent or not applicable. The canonical matrix with per-cell
|
|
35
35
|
notes and platform carve-outs lives in
|
|
36
|
-
[`lazily-spec` § Cross-Language Coverage](
|
|
36
|
+
[`lazily-spec` § Cross-Language Coverage](https://github.com/lazily-hub/lazily-spec/blob/main/docs/coverage.md).
|
|
37
37
|
|
|
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`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
@@ -51,10 +51,13 @@ 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
|
+
| Broadcast topic (`TopicCell`) — independent cursors + durable replay + safe GC (`#lztopiccell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
55
|
+
| Competing-consumer work queue (`WorkQueueCell`) — exclusive leases + ack/nack + redelivery + DLQ (`#lzworkqueue`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
54
56
|
| 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`) | ✅ |
|
|
57
|
+
| RelayCell — conflating relay + `BackpressurePolicy` + `SpillStore` + `Transport` + Inbox/Outbox + Rate/Window/Expiry/Priority/keyed policies (`#relaycell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
56
58
|
| Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
57
59
|
| `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
60
|
+
| `CrdtTree` lossless document contract (`#lzcrdttree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
58
61
|
| Move-aware sequence CRDT (`SeqCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
59
62
|
| Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
60
63
|
| Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
@@ -65,6 +68,7 @@ notes and platform carve-outs lives in
|
|
|
65
68
|
| Cross-process zero-copy transport (`BlobBackend` / shm / arrow) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
66
69
|
| Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
67
70
|
| Reliable sync — resync coordinator + at-least-once durable outbox + OR-set/LWW liveness (`#lzsync`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
71
|
+
| Storage-independent durable outbox (`OutboxStore` + shared outbox protocol; SQLite/Room/IndexedDB/file adapters) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
68
72
|
| Reliable-sync transport seam + full-duplex `SyncDriver` loop (`IpcSink`/`IpcSource`, `#sync-driver`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
69
73
|
| Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
70
74
|
| State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
@@ -74,6 +78,14 @@ notes and platform carve-outs lives in
|
|
|
74
78
|
| Permission boundary (`PeerPermissions` / `RemoteOp`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
75
79
|
| Capability negotiation (`SessionHandshake`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
76
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`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
77
89
|
<!-- coverage-table:end -->
|
|
78
90
|
|
|
79
91
|
Two JS ✅ marks are backed by runtime-specific mechanisms while keeping the core isomorphic:
|
|
@@ -115,10 +127,12 @@ JavaScript. The family is:
|
|
|
115
127
|
|
|
116
128
|
- **Slot** - lazy, memoized derived value;
|
|
117
129
|
- **Cell** - mutable source value;
|
|
118
|
-
- **Signal** - eager derived value that re-materializes as soon as a dependency
|
|
119
|
-
invalidates;
|
|
120
130
|
- **Effect** - side-effecting observer with cleanup.
|
|
121
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
|
+
|
|
122
136
|
Dependencies are discovered dynamically while a slot/effect/signal computes.
|
|
123
137
|
Invalidation is pull-based and glitch-free; `memo`/`Signal` use equality guards
|
|
124
138
|
to suppress downstream work when a recompute produces the same value. `batch`
|
|
@@ -337,6 +351,26 @@ tree.value(); // 1
|
|
|
337
351
|
tree.setValue("leaf", 99); // only the ancestor chain recomputes
|
|
338
352
|
```
|
|
339
353
|
|
|
354
|
+
## Competing-consumer work queue
|
|
355
|
+
|
|
356
|
+
The `queue` package exports `WorkQueueCell`, a pull-based local authority with
|
|
357
|
+
exclusive FIFO claims, stable item IDs, fresh delivery IDs per attempt,
|
|
358
|
+
worker-owned ack/nack, strict visibility-timeout redelivery, bounded attempts,
|
|
359
|
+
and DLQ routing. Every mutation reports exact `pending_len` / `is_empty` /
|
|
360
|
+
`in_flight_len` / `dead_letter_len` invalidation metadata.
|
|
361
|
+
|
|
362
|
+
```js
|
|
363
|
+
import { WorkQueueCell } from "@lazily-hub/lazily-js/queue";
|
|
364
|
+
|
|
365
|
+
const work = new WorkQueueCell({ visibility_timeout: 30, max_deliveries: 3 });
|
|
366
|
+
work.push("render-report");
|
|
367
|
+
const delivery = work.claim("worker-a", 100).returns;
|
|
368
|
+
work.ack("worker-a", delivery.delivery_id);
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
The instance serializes local claims; distributed/HA assignment still requires
|
|
372
|
+
a leader or consensus-committed assignment log.
|
|
373
|
+
|
|
340
374
|
## CRDTs
|
|
341
375
|
|
|
342
376
|
`SeqCrdt` is the move-aware sequence CRDT: each element has independent LWW
|
|
@@ -344,7 +378,10 @@ registers for value, position, and deletion, so a move is one position
|
|
|
344
378
|
assignment rather than delete plus reinsert. `TextCrdt` is a Fugue/RGA
|
|
345
379
|
character CRDT: concurrent same-point inserts are preserved, deletes are sticky
|
|
346
380
|
tombstones, and merge is commutative / associative / idempotent. Both expose
|
|
347
|
-
tombstone GC behind caller-supplied causal-stability watermarks.
|
|
381
|
+
tombstone GC behind caller-supplied causal-stability watermarks. `TextCrdt`
|
|
382
|
+
also satisfies the `CrdtTree` document contract: its snapshot is the delta from
|
|
383
|
+
an empty frontier, so full hydration and incremental exchange preserve the same
|
|
384
|
+
identity-bearing state.
|
|
348
385
|
|
|
349
386
|
```js
|
|
350
387
|
import { SeqCrdt } from "@lazily-hub/lazily-js/seq-crdt";
|
|
@@ -360,6 +397,16 @@ peer.insert(2, "!");
|
|
|
360
397
|
text.merge(peer); // converges
|
|
361
398
|
```
|
|
362
399
|
|
|
400
|
+
## Durable outbox stores
|
|
401
|
+
|
|
402
|
+
The root `Outbox` class owns one append/ack/prune/replay protocol over the
|
|
403
|
+
five-operation `OutboxStore` boundary. `InMemoryStore` exercises that path in
|
|
404
|
+
tests. Browsers can open an `IndexedDbStore` from
|
|
405
|
+
`@lazily-hub/lazily-js/indexeddb-outbox`; await `append` before transport send
|
|
406
|
+
and `ackThrough` before treating an acknowledgement as committed. Reopening the
|
|
407
|
+
same database and channel restores the durable cursor and only unacknowledged
|
|
408
|
+
frames.
|
|
409
|
+
|
|
363
410
|
## IPC wire types and capability negotiation
|
|
364
411
|
|
|
365
412
|
Every IPC value round-trips the canonical externally-tagged
|
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"
|
|
@@ -97,6 +129,11 @@
|
|
|
97
129
|
"types": "./src/lossless-tree-crdt.d.ts",
|
|
98
130
|
"default": "./src/lossless-tree-crdt.js"
|
|
99
131
|
},
|
|
132
|
+
"./indexeddb-outbox": {
|
|
133
|
+
"types": "./src/indexeddb-outbox.d.ts",
|
|
134
|
+
"browser": "./src/indexeddb-outbox.js",
|
|
135
|
+
"default": "./src/indexeddb-outbox.js"
|
|
136
|
+
},
|
|
100
137
|
"./state-projection": {
|
|
101
138
|
"types": "./src/state-projection.d.ts",
|
|
102
139
|
"default": "./src/state-projection.js"
|
|
@@ -111,7 +148,7 @@
|
|
|
111
148
|
}
|
|
112
149
|
},
|
|
113
150
|
"scripts": {
|
|
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",
|
|
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",
|
|
115
152
|
"test:formal": "node scripts/formal-check.mjs",
|
|
116
153
|
"bench": "node bench/context.bench.mjs",
|
|
117
154
|
"bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
|
|
@@ -125,6 +162,7 @@
|
|
|
125
162
|
"devDependencies": {
|
|
126
163
|
"@types/node": "^22.0.0",
|
|
127
164
|
"ajv": "^8.20.0",
|
|
165
|
+
"fake-indexeddb": "^6.2.5",
|
|
128
166
|
"tsx": "^4.19.0",
|
|
129
167
|
"typescript": "^5.6.0"
|
|
130
168
|
}
|
|
@@ -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
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -449,20 +449,40 @@ export class ResyncCoordinator {
|
|
|
449
449
|
}
|
|
450
450
|
|
|
451
451
|
export interface DurableOutbox {
|
|
452
|
-
append(epoch: number, msg: IpcMessage): void
|
|
453
|
-
ackThrough(epoch: number): void
|
|
452
|
+
append(epoch: number, msg: IpcMessage): void | Promise<void>;
|
|
453
|
+
ackThrough(epoch: number): void | Promise<void>;
|
|
454
454
|
replayFrom(cursor: number): Array<[number, IpcMessage]>;
|
|
455
455
|
retainedEpochs(): number[];
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
-
export
|
|
458
|
+
export interface OutboxStore {
|
|
459
|
+
put(epoch: number, frame: Uint8Array): void | Promise<void>;
|
|
460
|
+
deleteThrough(epoch: number): void | Promise<void>;
|
|
461
|
+
scanAfter(cursor: number): Array<[number, Uint8Array]>;
|
|
462
|
+
loadCursor(): number;
|
|
463
|
+
saveCursor(epoch: number): void | Promise<void>;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export class Outbox<S extends OutboxStore = OutboxStore> implements DurableOutbox {
|
|
467
|
+
constructor(store: S);
|
|
468
|
+
readonly store: S;
|
|
459
469
|
ackedThrough: number;
|
|
460
|
-
append(epoch: number, msg: IpcMessage): void
|
|
461
|
-
ackThrough(epoch: number): void
|
|
470
|
+
append(epoch: number, msg: IpcMessage): void | Promise<void>;
|
|
471
|
+
ackThrough(epoch: number): void | Promise<void>;
|
|
462
472
|
replayFrom(cursor: number): Array<[number, IpcMessage]>;
|
|
463
473
|
retainedEpochs(): number[];
|
|
464
474
|
}
|
|
465
475
|
|
|
476
|
+
export class InMemoryStore implements OutboxStore {
|
|
477
|
+
put(epoch: number, frame: Uint8Array): void;
|
|
478
|
+
deleteThrough(epoch: number): void;
|
|
479
|
+
scanAfter(cursor: number): Array<[number, Uint8Array]>;
|
|
480
|
+
loadCursor(): number;
|
|
481
|
+
saveCursor(epoch: number): void;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export class InMemoryOutbox extends Outbox<InMemoryStore> {}
|
|
485
|
+
|
|
466
486
|
export class OrSet {
|
|
467
487
|
add(tag: string): void;
|
|
468
488
|
removeObserved(tags: Iterable<string>): void;
|