@optimystic/db-p2p 0.22.0 → 0.24.1
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/dist/src/cluster/client.d.ts +10 -0
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +30 -1
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +39 -14
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +226 -119
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/service.d.ts +32 -1
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +43 -2
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cohort-topic/host.js +34 -11
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/cohort-topic/stream-util.d.ts +37 -7
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +77 -19
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +9 -3
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +88 -2
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +134 -28
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +25 -1
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/logger.d.ts +17 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +19 -2
- package/dist/src/logger.js.map +1 -1
- package/dist/src/matchmaking/query-transport.js +3 -3
- package/dist/src/matchmaking/query-transport.js.map +1 -1
- package/dist/src/owned-block-seed.d.ts +6 -3
- package/dist/src/owned-block-seed.d.ts.map +1 -1
- package/dist/src/owned-block-seed.js +16 -3
- package/dist/src/owned-block-seed.js.map +1 -1
- package/dist/src/peer-address-book.d.ts +72 -0
- package/dist/src/peer-address-book.d.ts.map +1 -0
- package/dist/src/peer-address-book.js +123 -0
- package/dist/src/peer-address-book.js.map +1 -0
- package/dist/src/reactivity/notify-transport.d.ts +4 -4
- package/dist/src/reactivity/notify-transport.js +6 -6
- package/dist/src/reactivity/notify-transport.js.map +1 -1
- package/dist/src/reactivity/push-state-gossip.js +2 -2
- package/dist/src/reactivity/push-state-gossip.js.map +1 -1
- package/dist/src/reactivity/recover-transport.d.ts +6 -2
- package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
- package/dist/src/reactivity/recover-transport.js +7 -3
- package/dist/src/reactivity/recover-transport.js.map +1 -1
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +11 -2
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +30 -0
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +95 -3
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +62 -9
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +242 -73
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/rn.d.ts +3 -0
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +3 -0
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +83 -0
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
- package/dist/src/storage/cached-raw-storage.js +152 -0
- package/dist/src/storage/cached-raw-storage.js.map +1 -0
- package/dist/src/storage/cached-store-driver.d.ts +186 -0
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
- package/dist/src/storage/cached-store-driver.js +775 -0
- package/dist/src/storage/cached-store-driver.js.map +1 -0
- package/dist/src/storage/i-raw-storage.d.ts +12 -5
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +234 -0
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
- package/dist/src/storage/shared-cache-pool.js +354 -0
- package/dist/src/storage/shared-cache-pool.js.map +1 -0
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
- package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
- package/dist/src/testing/raw-storage-conformance.js +35 -2
- package/dist/src/testing/raw-storage-conformance.js.map +1 -1
- package/package.json +3 -3
- package/readme.md +668 -668
- package/src/cluster/block-transfer.ts +424 -424
- package/src/cluster/client.ts +119 -88
- package/src/cluster/cluster-error.ts +64 -64
- package/src/cluster/cluster-policy.ts +203 -203
- package/src/cluster/cluster-repo.ts +242 -122
- package/src/cluster/cluster-size-coupling.ts +45 -45
- package/src/cluster/commit-cert.ts +139 -139
- package/src/cluster/i-transaction-state-store.ts +43 -43
- package/src/cluster/memory-transaction-state-store.ts +56 -56
- package/src/cluster/peer-key-binding.ts +37 -37
- package/src/cluster/persistent-transaction-state-store.ts +92 -92
- package/src/cluster/quorum-restore.ts +223 -223
- package/src/cluster/reconcile-block.ts +203 -203
- package/src/cluster/service.ts +293 -241
- package/src/cluster/supermajority-coupling.ts +37 -37
- package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
- package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
- package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
- package/src/cohort-topic/change-bridge.ts +109 -109
- package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
- package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
- package/src/cohort-topic/fret-trust-anchor.ts +153 -153
- package/src/cohort-topic/host.ts +42 -11
- package/src/cohort-topic/index.ts +13 -13
- package/src/cohort-topic/membership-publish-sink.ts +20 -20
- package/src/cohort-topic/membership-source.ts +68 -68
- package/src/cohort-topic/peer-codec.ts +31 -31
- package/src/cohort-topic/peer-sig.ts +86 -86
- package/src/cohort-topic/protocols.ts +71 -71
- package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
- package/src/cohort-topic/size-estimator.ts +16 -16
- package/src/cohort-topic/stream-util.ts +79 -19
- package/src/cohort-topic/threshold-crypto.ts +239 -239
- package/src/cohort-topic/topic-router.ts +77 -77
- package/src/dispute/arbitrator-selection.ts +138 -138
- package/src/dispute/cascade.ts +524 -524
- package/src/dispute/dispute-service.ts +11 -5
- package/src/dispute/invalidation.ts +625 -625
- package/src/inbound-authorization.ts +190 -190
- package/src/index.ts +52 -49
- package/src/libp2p-key-network.ts +1120 -990
- package/src/libp2p-node-base.ts +1675 -1651
- package/src/libp2p-node-rn.ts +30 -30
- package/src/libp2p-node.ts +36 -36
- package/src/logger.ts +19 -2
- package/src/matchmaking/aggregate-counts.ts +104 -104
- package/src/matchmaking/index.ts +20 -20
- package/src/matchmaking/module.ts +363 -363
- package/src/matchmaking/protocols.ts +51 -51
- package/src/matchmaking/provider-manager.ts +95 -95
- package/src/matchmaking/query-handler.ts +88 -88
- package/src/matchmaking/query-transport.ts +3 -3
- package/src/matchmaking/seeker-manager.ts +64 -64
- package/src/matchmaking/seeker-walk-client.ts +293 -293
- package/src/matchmaking/traffic-validation.ts +195 -195
- package/src/optimystic-node.ts +36 -36
- package/src/owned-block-seed.ts +53 -40
- package/src/peer-address-book.ts +149 -0
- package/src/protocol-limits.ts +33 -33
- package/src/reactivity/forwarder-host.ts +438 -438
- package/src/reactivity/index.ts +19 -19
- package/src/reactivity/notify-transport.ts +144 -144
- package/src/reactivity/origination-manager.ts +192 -192
- package/src/reactivity/protocols.ts +61 -61
- package/src/reactivity/push-state-gossip.ts +291 -291
- package/src/reactivity/recover-transport.ts +7 -3
- package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
- package/src/reactivity/subscriber-registry.ts +96 -96
- package/src/reactivity/subscription-manager.ts +450 -450
- package/src/reactivity/topic-bytes.ts +37 -37
- package/src/repo/client.ts +12 -2
- package/src/repo/cluster-coordinator.ts +99 -3
- package/src/repo/coordinator-repo.ts +281 -74
- package/src/repo/types.ts +7 -7
- package/src/rn.ts +39 -36
- package/src/rpc-deadline.ts +45 -45
- package/src/storage/arachnode-partition.ts +74 -74
- package/src/storage/cached-raw-storage.ts +180 -0
- package/src/storage/cached-store-driver.ts +859 -0
- package/src/storage/i-kv-store.ts +8 -8
- package/src/storage/i-raw-storage.ts +12 -5
- package/src/storage/kv-raw-storage.ts +135 -135
- package/src/storage/memory-kv-store.ts +28 -28
- package/src/storage/memory-storage.ts +25 -25
- package/src/storage/memory-store-driver.ts +157 -157
- package/src/storage/raw-store-codec.ts +42 -42
- package/src/storage/raw-store-driver.ts +80 -80
- package/src/storage/ring-selector.ts +317 -317
- package/src/storage/ring-shift-coordinator.ts +271 -271
- package/src/storage/shared-cache-pool.ts +452 -0
- package/src/storage/storage-repo.ts +1014 -1014
- package/src/testing/cohort-topic-mesh-harness.ts +673 -663
- package/src/testing/index.ts +8 -8
- package/src/testing/matchmaking-mesh-harness.ts +475 -475
- package/src/testing/raw-storage-conformance.ts +453 -417
- package/src/testing/reactivity-mesh-harness.ts +922 -922
- package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
- package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
- package/dist/src/storage/restoration-coordinator-v2.js +0 -172
- package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
|
@@ -1,291 +1,291 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reactivity — `PushState` intra-cohort gossip driver (`docs/reactivity.md` §Replay window,
|
|
3
|
-
* §Forwarder-cohort state).
|
|
4
|
-
*
|
|
5
|
-
* The forwarder-cohort replay ring + dedupe window are the substrate that makes the doc's "**any** cohort
|
|
6
|
-
* member — not just the primary — can serve a replay/backfill" property real: a member that missed an
|
|
7
|
-
* origin dial still ends up holding the entry, so a backfill (12.4) survives a primary failover. The
|
|
8
|
-
* db-core {@link PushState} already owns the codec ({@link encodePushStateGossipV1} /
|
|
9
|
-
* {@link decodePushStateGossipV1}, {@link PushState.serializeGossip} / {@link PushState.mergeGossip}); this
|
|
10
|
-
* driver is the **replication** layer that actually broadcasts and merges that state over libp2p on a
|
|
11
|
-
* periodic cadence — the backstop to the live fan-out ({@link import("./forwarder-host.js")}).
|
|
12
|
-
*
|
|
13
|
-
* **Transport reuse.** Rather than stand up a new transport, this rides the cohort gossip transport's
|
|
14
|
-
* {@link import("../cohort-topic/cohort-gossip-transport.js").FretCohortGossipTransport.broadcastOver}
|
|
15
|
-
* seam — the same one the promote-notice broadcast reuses — so the cohort peer resolution, self-exclusion,
|
|
16
|
-
* and per-peer failure swallowing are shared. Frames ride the dedicated
|
|
17
|
-
* {@link PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP} (one-way), fanned to the FRET-assembled cohort around each
|
|
18
|
-
* served collection's cohort coord. Inbound frames arrive on this node's `push-state-gossip` protocol
|
|
19
|
-
* handler (registered by the node-wiring ticket) and route to {@link ReactivityPushStateGossipDriver.deliver}.
|
|
20
|
-
*
|
|
21
|
-
* **Cadence.** Its own unref'd timer (parallel to the cohort host's single `setInterval`), reusing
|
|
22
|
-
* {@link DEFAULT_GOSSIP_INTERVAL_MS} and the host's re-entrancy / `stopped` / `unref` discipline verbatim.
|
|
23
|
-
* Reactivity collections are not 1:1 with the host's per-`CoordEngine` tick and the forwarder host owns the
|
|
24
|
-
* live {@link PushState} set, so a separate timer is the clean seam now; a future consolidation could fold
|
|
25
|
-
* this into the host tick via a hook, but a separate unref'd timer cannot pin an idle process.
|
|
26
|
-
*
|
|
27
|
-
* The **driver** touches neither the libp2p node assembly nor FRET cohort assembly — it depends only on the
|
|
28
|
-
* `broadcastOver` seam + db-core codec, so it is unit-testable with a fake transport and real `PushState`s.
|
|
29
|
-
* The co-located {@link registerPushStateGossipHandler} (the inbound protocol-handler registration) does
|
|
30
|
-
* bind libp2p, mirroring {@link import("./notify-transport.js").registerNotifyHandler} — the driver class
|
|
31
|
-
* stays pure; only that one helper reaches the node.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import {
|
|
35
|
-
encodePushStateGossipV1,
|
|
36
|
-
decodePushStateGossipV1,
|
|
37
|
-
type PushState,
|
|
38
|
-
type PushStateGossipV1,
|
|
39
|
-
type RingCoord,
|
|
40
|
-
} from "@optimystic/db-core";
|
|
41
|
-
import type { Libp2p } from "libp2p";
|
|
42
|
-
import type { Connection, Stream } from "@libp2p/interface";
|
|
43
|
-
import {
|
|
44
|
-
import type { FretCohortGossipTransport } from "../cohort-topic/cohort-gossip-transport.js";
|
|
45
|
-
import { DEFAULT_GOSSIP_INTERVAL_MS } from "../cohort-topic/cohort-gossip-driver.js";
|
|
46
|
-
import { DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
|
|
47
|
-
import { PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP } from "./protocols.js";
|
|
48
|
-
import { createLogger } from "../logger.js";
|
|
49
|
-
|
|
50
|
-
const log = createLogger("reactivity-push-state-gossip");
|
|
51
|
-
|
|
52
|
-
/** Encode a probe frame with no size ceiling so the driver can measure its true length before bounding. */
|
|
53
|
-
const NO_FRAME_LIMIT = Number.MAX_SAFE_INTEGER;
|
|
54
|
-
|
|
55
|
-
/** A live forwarder collection to gossip: its {@link PushState} and the cohort coord to broadcast to. */
|
|
56
|
-
export interface ReactivityGossipCollection {
|
|
57
|
-
readonly pushState: PushState;
|
|
58
|
-
readonly cohortCoord: RingCoord;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** A truncation event: a collection's serialized replay ring exceeded the frame bound and was clipped. */
|
|
62
|
-
export interface PushStateGossipTruncation {
|
|
63
|
-
readonly collectionId: string;
|
|
64
|
-
readonly topicId: string;
|
|
65
|
-
/** Most-recent replay entries kept in the broadcast frame. */
|
|
66
|
-
readonly kept: number;
|
|
67
|
-
/** Replay entries the ring actually held this round. */
|
|
68
|
-
readonly total: number;
|
|
69
|
-
/** Bytes of the broadcast (clipped) frame. */
|
|
70
|
-
readonly frameBytes: number;
|
|
71
|
-
/** The configured per-frame ceiling. */
|
|
72
|
-
readonly maxBytes: number;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Construction inputs for a {@link ReactivityPushStateGossipDriver}. */
|
|
76
|
-
export interface ReactivityPushStateGossipDriverDeps {
|
|
77
|
-
/** Reuse the node's cohort gossip transport (`broadcastOver`) — no new transport. */
|
|
78
|
-
readonly gossipTransport: Pick<FretCohortGossipTransport, "broadcastOver">;
|
|
79
|
-
/** The live forwarder collections to gossip each round, with the cohort coord to broadcast to. */
|
|
80
|
-
readonly liveCollections: () => Iterable<ReactivityGossipCollection>;
|
|
81
|
-
/** Resolve the collection owning an inbound frame (by collectionId/topicId) → its {@link PushState}, or undefined. */
|
|
82
|
-
readonly pushStateForGossip: (g: PushStateGossipV1) => PushState | undefined;
|
|
83
|
-
/** Inbound authenticity gate: is `fromPeerId` a member of the cohort for the frame's coord? Absent ⇒ accept all. */
|
|
84
|
-
readonly isCohortMember?: (fromPeerId: string, g: PushStateGossipV1) => boolean;
|
|
85
|
-
/** Cadence interval. Default {@link DEFAULT_GOSSIP_INTERVAL_MS}. */
|
|
86
|
-
readonly intervalMs?: number;
|
|
87
|
-
/** Per-frame ceiling (frame, including the length prefix, stays within this). Default {@link DEFAULT_STREAM_MAX_BYTES}. */
|
|
88
|
-
readonly maxBytes?: number;
|
|
89
|
-
/** Reserved for parity with the cohort host's tick discipline; unused today (gossip carries its own `receivedAt`). */
|
|
90
|
-
readonly clock?: () => number;
|
|
91
|
-
/** Observe a clipped (over-bound) frame, in addition to the "no silent cap" log. Test/diagnostic seam. */
|
|
92
|
-
readonly onTruncate?: (info: PushStateGossipTruncation) => void;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Drives {@link PushState} convergence across a forwarder cohort: each round it broadcasts every live
|
|
97
|
-
* collection's serialized push-state to the cohort (clipped to the frame bound), and each inbound frame is
|
|
98
|
-
* gated, resolved to the owning collection, and merged. Build one per node and bind its inbound
|
|
99
|
-
* {@link ReactivityPushStateGossipDriver.deliver} to the `push-state-gossip` protocol handler.
|
|
100
|
-
*/
|
|
101
|
-
export class ReactivityPushStateGossipDriver {
|
|
102
|
-
private readonly gossipTransport: Pick<FretCohortGossipTransport, "broadcastOver">;
|
|
103
|
-
private readonly liveCollections: () => Iterable<ReactivityGossipCollection>;
|
|
104
|
-
private readonly pushStateForGossip: (g: PushStateGossipV1) => PushState | undefined;
|
|
105
|
-
private readonly isCohortMember?: (fromPeerId: string, g: PushStateGossipV1) => boolean;
|
|
106
|
-
private readonly intervalMs: number;
|
|
107
|
-
private readonly maxBytes: number;
|
|
108
|
-
private readonly onTruncate?: (info: PushStateGossipTruncation) => void;
|
|
109
|
-
|
|
110
|
-
private timer?: ReturnType<typeof setInterval>;
|
|
111
|
-
private stopped = false;
|
|
112
|
-
/** Re-entrancy guard mirroring the host tick: skip a round that overlaps a slow prior one. */
|
|
113
|
-
private rounding = false;
|
|
114
|
-
|
|
115
|
-
constructor(deps: ReactivityPushStateGossipDriverDeps) {
|
|
116
|
-
this.gossipTransport = deps.gossipTransport;
|
|
117
|
-
this.liveCollections = deps.liveCollections;
|
|
118
|
-
this.pushStateForGossip = deps.pushStateForGossip;
|
|
119
|
-
this.isCohortMember = deps.isCohortMember;
|
|
120
|
-
this.intervalMs = deps.intervalMs ?? DEFAULT_GOSSIP_INTERVAL_MS;
|
|
121
|
-
this.maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
122
|
-
this.onTruncate = deps.onTruncate;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Begin the unref'd cadence timer. Idempotent; a no-op once {@link stop} has run. */
|
|
126
|
-
start(): void {
|
|
127
|
-
if (this.stopped || this.timer !== undefined) {
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
const timer = setInterval((): void => {
|
|
131
|
-
this.round();
|
|
132
|
-
}, this.intervalMs);
|
|
133
|
-
// Node timers keep the event loop alive; push-state gossip must not pin a process that is otherwise idle.
|
|
134
|
-
(timer as { unref?: () => void }).unref?.();
|
|
135
|
-
this.timer = timer;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* One round: broadcast each live collection's serialized {@link PushState} to its cohort, clipped to the
|
|
140
|
-
* frame bound. Per-collection isolated (one collection's fault never skips the rest) and `stopped`-gated
|
|
141
|
-
* mid-loop, mirroring the host tick.
|
|
142
|
-
*/
|
|
143
|
-
round(): void {
|
|
144
|
-
if (this.stopped || this.rounding) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
this.rounding = true;
|
|
148
|
-
try {
|
|
149
|
-
for (const { pushState, cohortCoord } of this.liveCollections()) {
|
|
150
|
-
if (this.stopped) {
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
try {
|
|
154
|
-
this.broadcastOne(pushState, cohortCoord);
|
|
155
|
-
} catch (err) {
|
|
156
|
-
log("push-state gossip round failed for a collection (isolated): %o", err);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
} finally {
|
|
160
|
-
this.rounding = false;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Inbound handler body: decode → membership gate → resolve owning collection → merge. Never throws on a
|
|
166
|
-
* bad frame (a malformed/forged/foreign frame is logged and dropped), so a stream handler can call it
|
|
167
|
-
* directly. `mergeGossip` independently guards a collection/topic mismatch, so the resolve step is a fast
|
|
168
|
-
* pre-filter, not the only line of defense.
|
|
169
|
-
*/
|
|
170
|
-
deliver(fromPeerId: string, frame: Uint8Array): void {
|
|
171
|
-
let g: PushStateGossipV1;
|
|
172
|
-
try {
|
|
173
|
-
g = decodePushStateGossipV1(frame, this.maxBytes);
|
|
174
|
-
} catch (err) {
|
|
175
|
-
log("dropped an undecodable push-state gossip frame from %s: %o", fromPeerId, err);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
if (this.isCohortMember !== undefined && !this.isCohortMember(fromPeerId, g)) {
|
|
179
|
-
log("dropped push-state gossip from non-member %s for collection=%s topic=%s", fromPeerId, g.collectionId, g.topicId);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
const pushState = this.pushStateForGossip(g);
|
|
183
|
-
if (pushState === undefined) {
|
|
184
|
-
return; // gossip for a collection this node does not serve — nothing to merge into.
|
|
185
|
-
}
|
|
186
|
-
try {
|
|
187
|
-
pushState.mergeGossip(g);
|
|
188
|
-
} catch (err) {
|
|
189
|
-
log("push-state mergeGossip threw (isolated) for collection=%s: %o", g.collectionId, err);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Stop the cadence: short-circuit any in-flight/future round and clear the timer. */
|
|
194
|
-
stop(): void {
|
|
195
|
-
this.stopped = true;
|
|
196
|
-
if (this.timer !== undefined) {
|
|
197
|
-
clearInterval(this.timer);
|
|
198
|
-
this.timer = undefined;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Serialize one collection, clip to the frame bound, and broadcast to its cohort. */
|
|
203
|
-
private broadcastOne(pushState: PushState, cohortCoord: RingCoord): void {
|
|
204
|
-
const g = pushState.serializeGossip();
|
|
205
|
-
const { frame, kept, total } = this.boundedFrame(g);
|
|
206
|
-
if (kept < total) {
|
|
207
|
-
// "No silent caps" (AGENTS.md): a clipped ring is always surfaced. Convergence still completes —
|
|
208
|
-
// each entry replicates while it is within the most-recent window across successive rounds, and
|
|
209
|
-
// `mergeGossip` unions ring entries so a partial frame loses nothing already held by a peer.
|
|
210
|
-
log("push-state gossip frame clipped for collection=%s: kept %d/%d most-recent replay entries to fit maxBytes=%d", g.collectionId, kept, total, this.maxBytes);
|
|
211
|
-
this.onTruncate?.({ collectionId: g.collectionId, topicId: g.topicId, kept, total, frameBytes: frame.length, maxBytes: this.maxBytes });
|
|
212
|
-
}
|
|
213
|
-
this.gossipTransport.broadcastOver(PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP, cohortCoord, frame);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Build the broadcast frame for one serialized push-state, kept within {@link maxBytes}.
|
|
218
|
-
*
|
|
219
|
-
* The whole replay ring (`W` up to 256 full `NotificationV1`s) can exceed the frame bound. When it does,
|
|
220
|
-
* keep the full (small) dedupe window and the **most-recent-N** replay entries that fit — sliced from the
|
|
221
|
-
* high-revision end, since a lagging subscriber backfills the newest gap first and older revisions roll
|
|
222
|
-
* to the parent checkpoint anyway. Frame size is monotone in the entry count, so binary-search the
|
|
223
|
-
* high-water N rather than re-encoding per entry.
|
|
224
|
-
*/
|
|
225
|
-
private boundedFrame(g: PushStateGossipV1): { frame: Uint8Array; kept: number; total: number } {
|
|
226
|
-
const total = g.replayBuffer.entries.length;
|
|
227
|
-
const full = encodePushStateGossipV1(g, NO_FRAME_LIMIT);
|
|
228
|
-
if (full.length <= this.maxBytes) {
|
|
229
|
-
return { frame: full, kept: total, total };
|
|
230
|
-
}
|
|
231
|
-
// Largest most-recent-N replay slice whose frame still fits. `encodeSlice(g, 0)` (dedupe window only)
|
|
232
|
-
// is the floor we ship if even that overruns the bound (pathological — the dedupe window is small).
|
|
233
|
-
let lo = 0;
|
|
234
|
-
let hi = total;
|
|
235
|
-
let bestN = 0;
|
|
236
|
-
let bestFrame = this.encodeSlice(g, 0);
|
|
237
|
-
while (lo <= hi) {
|
|
238
|
-
const mid = (lo + hi) >>> 1;
|
|
239
|
-
const frame = this.encodeSlice(g, mid);
|
|
240
|
-
if (frame.length <= this.maxBytes) {
|
|
241
|
-
bestN = mid;
|
|
242
|
-
bestFrame = frame;
|
|
243
|
-
lo = mid + 1;
|
|
244
|
-
} else {
|
|
245
|
-
hi = mid - 1;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
return { frame: bestFrame, kept: bestN, total };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** Encode `g` with only its most-recent `keep` replay entries retained (the full dedupe window is kept). */
|
|
252
|
-
private encodeSlice(g: PushStateGossipV1, keep: number): Uint8Array {
|
|
253
|
-
const entries = g.replayBuffer.entries;
|
|
254
|
-
const kept = keep >= entries.length ? entries : entries.slice(entries.length - keep);
|
|
255
|
-
const sliced: PushStateGossipV1 = {
|
|
256
|
-
...g,
|
|
257
|
-
replayBuffer: { capacity: g.replayBuffer.capacity, entries: kept },
|
|
258
|
-
};
|
|
259
|
-
return encodePushStateGossipV1(sliced, NO_FRAME_LIMIT);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Register the inbound push-state-gossip protocol handler: read one bounded frame and hand it to
|
|
265
|
-
* {@link ReactivityPushStateGossipDriver.deliver} (which decodes, membership-gates, and merges), then close.
|
|
266
|
-
* One-way — no reply frame, matching the cohort-topic gossip handlers. A read error aborts the stream and
|
|
267
|
-
* {@link ReactivityPushStateGossipDriver.deliver} swallows a decode/forge/foreign failure, so the handler
|
|
268
|
-
* never throws on the stream. Mirrors {@link import("./notify-transport.js").registerNotifyHandler}.
|
|
269
|
-
*/
|
|
270
|
-
export function registerPushStateGossipHandler(
|
|
271
|
-
node: Libp2p,
|
|
272
|
-
protocol: string,
|
|
273
|
-
driver: Pick<ReactivityPushStateGossipDriver, "deliver">,
|
|
274
|
-
maxBytes = DEFAULT_STREAM_MAX_BYTES,
|
|
275
|
-
): void {
|
|
276
|
-
void node.handle(protocol, (stream: Stream, connection: Connection) => {
|
|
277
|
-
void (async (): Promise<void> => {
|
|
278
|
-
try {
|
|
279
|
-
const frame = await
|
|
280
|
-
driver.deliver(connection.remotePeer.toString(), frame);
|
|
281
|
-
await stream.close();
|
|
282
|
-
} catch {
|
|
283
|
-
try {
|
|
284
|
-
stream.abort(new Error("reactivity push-state gossip stream handler error"));
|
|
285
|
-
} catch {
|
|
286
|
-
/* already aborted */
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
})();
|
|
290
|
-
});
|
|
291
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reactivity — `PushState` intra-cohort gossip driver (`docs/reactivity.md` §Replay window,
|
|
3
|
+
* §Forwarder-cohort state).
|
|
4
|
+
*
|
|
5
|
+
* The forwarder-cohort replay ring + dedupe window are the substrate that makes the doc's "**any** cohort
|
|
6
|
+
* member — not just the primary — can serve a replay/backfill" property real: a member that missed an
|
|
7
|
+
* origin dial still ends up holding the entry, so a backfill (12.4) survives a primary failover. The
|
|
8
|
+
* db-core {@link PushState} already owns the codec ({@link encodePushStateGossipV1} /
|
|
9
|
+
* {@link decodePushStateGossipV1}, {@link PushState.serializeGossip} / {@link PushState.mergeGossip}); this
|
|
10
|
+
* driver is the **replication** layer that actually broadcasts and merges that state over libp2p on a
|
|
11
|
+
* periodic cadence — the backstop to the live fan-out ({@link import("./forwarder-host.js")}).
|
|
12
|
+
*
|
|
13
|
+
* **Transport reuse.** Rather than stand up a new transport, this rides the cohort gossip transport's
|
|
14
|
+
* {@link import("../cohort-topic/cohort-gossip-transport.js").FretCohortGossipTransport.broadcastOver}
|
|
15
|
+
* seam — the same one the promote-notice broadcast reuses — so the cohort peer resolution, self-exclusion,
|
|
16
|
+
* and per-peer failure swallowing are shared. Frames ride the dedicated
|
|
17
|
+
* {@link PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP} (one-way), fanned to the FRET-assembled cohort around each
|
|
18
|
+
* served collection's cohort coord. Inbound frames arrive on this node's `push-state-gossip` protocol
|
|
19
|
+
* handler (registered by the node-wiring ticket) and route to {@link ReactivityPushStateGossipDriver.deliver}.
|
|
20
|
+
*
|
|
21
|
+
* **Cadence.** Its own unref'd timer (parallel to the cohort host's single `setInterval`), reusing
|
|
22
|
+
* {@link DEFAULT_GOSSIP_INTERVAL_MS} and the host's re-entrancy / `stopped` / `unref` discipline verbatim.
|
|
23
|
+
* Reactivity collections are not 1:1 with the host's per-`CoordEngine` tick and the forwarder host owns the
|
|
24
|
+
* live {@link PushState} set, so a separate timer is the clean seam now; a future consolidation could fold
|
|
25
|
+
* this into the host tick via a hook, but a separate unref'd timer cannot pin an idle process.
|
|
26
|
+
*
|
|
27
|
+
* The **driver** touches neither the libp2p node assembly nor FRET cohort assembly — it depends only on the
|
|
28
|
+
* `broadcastOver` seam + db-core codec, so it is unit-testable with a fake transport and real `PushState`s.
|
|
29
|
+
* The co-located {@link registerPushStateGossipHandler} (the inbound protocol-handler registration) does
|
|
30
|
+
* bind libp2p, mirroring {@link import("./notify-transport.js").registerNotifyHandler} — the driver class
|
|
31
|
+
* stays pure; only that one helper reaches the node.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
encodePushStateGossipV1,
|
|
36
|
+
decodePushStateGossipV1,
|
|
37
|
+
type PushState,
|
|
38
|
+
type PushStateGossipV1,
|
|
39
|
+
type RingCoord,
|
|
40
|
+
} from "@optimystic/db-core";
|
|
41
|
+
import type { Libp2p } from "libp2p";
|
|
42
|
+
import type { Connection, Stream } from "@libp2p/interface";
|
|
43
|
+
import { readFramed } from "p2p-fret";
|
|
44
|
+
import type { FretCohortGossipTransport } from "../cohort-topic/cohort-gossip-transport.js";
|
|
45
|
+
import { DEFAULT_GOSSIP_INTERVAL_MS } from "../cohort-topic/cohort-gossip-driver.js";
|
|
46
|
+
import { DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
|
|
47
|
+
import { PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP } from "./protocols.js";
|
|
48
|
+
import { createLogger } from "../logger.js";
|
|
49
|
+
|
|
50
|
+
const log = createLogger("reactivity-push-state-gossip");
|
|
51
|
+
|
|
52
|
+
/** Encode a probe frame with no size ceiling so the driver can measure its true length before bounding. */
|
|
53
|
+
const NO_FRAME_LIMIT = Number.MAX_SAFE_INTEGER;
|
|
54
|
+
|
|
55
|
+
/** A live forwarder collection to gossip: its {@link PushState} and the cohort coord to broadcast to. */
|
|
56
|
+
export interface ReactivityGossipCollection {
|
|
57
|
+
readonly pushState: PushState;
|
|
58
|
+
readonly cohortCoord: RingCoord;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A truncation event: a collection's serialized replay ring exceeded the frame bound and was clipped. */
|
|
62
|
+
export interface PushStateGossipTruncation {
|
|
63
|
+
readonly collectionId: string;
|
|
64
|
+
readonly topicId: string;
|
|
65
|
+
/** Most-recent replay entries kept in the broadcast frame. */
|
|
66
|
+
readonly kept: number;
|
|
67
|
+
/** Replay entries the ring actually held this round. */
|
|
68
|
+
readonly total: number;
|
|
69
|
+
/** Bytes of the broadcast (clipped) frame. */
|
|
70
|
+
readonly frameBytes: number;
|
|
71
|
+
/** The configured per-frame ceiling. */
|
|
72
|
+
readonly maxBytes: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Construction inputs for a {@link ReactivityPushStateGossipDriver}. */
|
|
76
|
+
export interface ReactivityPushStateGossipDriverDeps {
|
|
77
|
+
/** Reuse the node's cohort gossip transport (`broadcastOver`) — no new transport. */
|
|
78
|
+
readonly gossipTransport: Pick<FretCohortGossipTransport, "broadcastOver">;
|
|
79
|
+
/** The live forwarder collections to gossip each round, with the cohort coord to broadcast to. */
|
|
80
|
+
readonly liveCollections: () => Iterable<ReactivityGossipCollection>;
|
|
81
|
+
/** Resolve the collection owning an inbound frame (by collectionId/topicId) → its {@link PushState}, or undefined. */
|
|
82
|
+
readonly pushStateForGossip: (g: PushStateGossipV1) => PushState | undefined;
|
|
83
|
+
/** Inbound authenticity gate: is `fromPeerId` a member of the cohort for the frame's coord? Absent ⇒ accept all. */
|
|
84
|
+
readonly isCohortMember?: (fromPeerId: string, g: PushStateGossipV1) => boolean;
|
|
85
|
+
/** Cadence interval. Default {@link DEFAULT_GOSSIP_INTERVAL_MS}. */
|
|
86
|
+
readonly intervalMs?: number;
|
|
87
|
+
/** Per-frame ceiling (frame, including the length prefix, stays within this). Default {@link DEFAULT_STREAM_MAX_BYTES}. */
|
|
88
|
+
readonly maxBytes?: number;
|
|
89
|
+
/** Reserved for parity with the cohort host's tick discipline; unused today (gossip carries its own `receivedAt`). */
|
|
90
|
+
readonly clock?: () => number;
|
|
91
|
+
/** Observe a clipped (over-bound) frame, in addition to the "no silent cap" log. Test/diagnostic seam. */
|
|
92
|
+
readonly onTruncate?: (info: PushStateGossipTruncation) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Drives {@link PushState} convergence across a forwarder cohort: each round it broadcasts every live
|
|
97
|
+
* collection's serialized push-state to the cohort (clipped to the frame bound), and each inbound frame is
|
|
98
|
+
* gated, resolved to the owning collection, and merged. Build one per node and bind its inbound
|
|
99
|
+
* {@link ReactivityPushStateGossipDriver.deliver} to the `push-state-gossip` protocol handler.
|
|
100
|
+
*/
|
|
101
|
+
export class ReactivityPushStateGossipDriver {
|
|
102
|
+
private readonly gossipTransport: Pick<FretCohortGossipTransport, "broadcastOver">;
|
|
103
|
+
private readonly liveCollections: () => Iterable<ReactivityGossipCollection>;
|
|
104
|
+
private readonly pushStateForGossip: (g: PushStateGossipV1) => PushState | undefined;
|
|
105
|
+
private readonly isCohortMember?: (fromPeerId: string, g: PushStateGossipV1) => boolean;
|
|
106
|
+
private readonly intervalMs: number;
|
|
107
|
+
private readonly maxBytes: number;
|
|
108
|
+
private readonly onTruncate?: (info: PushStateGossipTruncation) => void;
|
|
109
|
+
|
|
110
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
111
|
+
private stopped = false;
|
|
112
|
+
/** Re-entrancy guard mirroring the host tick: skip a round that overlaps a slow prior one. */
|
|
113
|
+
private rounding = false;
|
|
114
|
+
|
|
115
|
+
constructor(deps: ReactivityPushStateGossipDriverDeps) {
|
|
116
|
+
this.gossipTransport = deps.gossipTransport;
|
|
117
|
+
this.liveCollections = deps.liveCollections;
|
|
118
|
+
this.pushStateForGossip = deps.pushStateForGossip;
|
|
119
|
+
this.isCohortMember = deps.isCohortMember;
|
|
120
|
+
this.intervalMs = deps.intervalMs ?? DEFAULT_GOSSIP_INTERVAL_MS;
|
|
121
|
+
this.maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
122
|
+
this.onTruncate = deps.onTruncate;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Begin the unref'd cadence timer. Idempotent; a no-op once {@link stop} has run. */
|
|
126
|
+
start(): void {
|
|
127
|
+
if (this.stopped || this.timer !== undefined) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const timer = setInterval((): void => {
|
|
131
|
+
this.round();
|
|
132
|
+
}, this.intervalMs);
|
|
133
|
+
// Node timers keep the event loop alive; push-state gossip must not pin a process that is otherwise idle.
|
|
134
|
+
(timer as { unref?: () => void }).unref?.();
|
|
135
|
+
this.timer = timer;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One round: broadcast each live collection's serialized {@link PushState} to its cohort, clipped to the
|
|
140
|
+
* frame bound. Per-collection isolated (one collection's fault never skips the rest) and `stopped`-gated
|
|
141
|
+
* mid-loop, mirroring the host tick.
|
|
142
|
+
*/
|
|
143
|
+
round(): void {
|
|
144
|
+
if (this.stopped || this.rounding) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.rounding = true;
|
|
148
|
+
try {
|
|
149
|
+
for (const { pushState, cohortCoord } of this.liveCollections()) {
|
|
150
|
+
if (this.stopped) {
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
this.broadcastOne(pushState, cohortCoord);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
log("push-state gossip round failed for a collection (isolated): %o", err);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
this.rounding = false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Inbound handler body: decode → membership gate → resolve owning collection → merge. Never throws on a
|
|
166
|
+
* bad frame (a malformed/forged/foreign frame is logged and dropped), so a stream handler can call it
|
|
167
|
+
* directly. `mergeGossip` independently guards a collection/topic mismatch, so the resolve step is a fast
|
|
168
|
+
* pre-filter, not the only line of defense.
|
|
169
|
+
*/
|
|
170
|
+
deliver(fromPeerId: string, frame: Uint8Array): void {
|
|
171
|
+
let g: PushStateGossipV1;
|
|
172
|
+
try {
|
|
173
|
+
g = decodePushStateGossipV1(frame, this.maxBytes);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
log("dropped an undecodable push-state gossip frame from %s: %o", fromPeerId, err);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (this.isCohortMember !== undefined && !this.isCohortMember(fromPeerId, g)) {
|
|
179
|
+
log("dropped push-state gossip from non-member %s for collection=%s topic=%s", fromPeerId, g.collectionId, g.topicId);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const pushState = this.pushStateForGossip(g);
|
|
183
|
+
if (pushState === undefined) {
|
|
184
|
+
return; // gossip for a collection this node does not serve — nothing to merge into.
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
pushState.mergeGossip(g);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
log("push-state mergeGossip threw (isolated) for collection=%s: %o", g.collectionId, err);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Stop the cadence: short-circuit any in-flight/future round and clear the timer. */
|
|
194
|
+
stop(): void {
|
|
195
|
+
this.stopped = true;
|
|
196
|
+
if (this.timer !== undefined) {
|
|
197
|
+
clearInterval(this.timer);
|
|
198
|
+
this.timer = undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Serialize one collection, clip to the frame bound, and broadcast to its cohort. */
|
|
203
|
+
private broadcastOne(pushState: PushState, cohortCoord: RingCoord): void {
|
|
204
|
+
const g = pushState.serializeGossip();
|
|
205
|
+
const { frame, kept, total } = this.boundedFrame(g);
|
|
206
|
+
if (kept < total) {
|
|
207
|
+
// "No silent caps" (AGENTS.md): a clipped ring is always surfaced. Convergence still completes —
|
|
208
|
+
// each entry replicates while it is within the most-recent window across successive rounds, and
|
|
209
|
+
// `mergeGossip` unions ring entries so a partial frame loses nothing already held by a peer.
|
|
210
|
+
log("push-state gossip frame clipped for collection=%s: kept %d/%d most-recent replay entries to fit maxBytes=%d", g.collectionId, kept, total, this.maxBytes);
|
|
211
|
+
this.onTruncate?.({ collectionId: g.collectionId, topicId: g.topicId, kept, total, frameBytes: frame.length, maxBytes: this.maxBytes });
|
|
212
|
+
}
|
|
213
|
+
this.gossipTransport.broadcastOver(PROTOCOL_REACTIVITY_PUSH_STATE_GOSSIP, cohortCoord, frame);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build the broadcast frame for one serialized push-state, kept within {@link maxBytes}.
|
|
218
|
+
*
|
|
219
|
+
* The whole replay ring (`W` up to 256 full `NotificationV1`s) can exceed the frame bound. When it does,
|
|
220
|
+
* keep the full (small) dedupe window and the **most-recent-N** replay entries that fit — sliced from the
|
|
221
|
+
* high-revision end, since a lagging subscriber backfills the newest gap first and older revisions roll
|
|
222
|
+
* to the parent checkpoint anyway. Frame size is monotone in the entry count, so binary-search the
|
|
223
|
+
* high-water N rather than re-encoding per entry.
|
|
224
|
+
*/
|
|
225
|
+
private boundedFrame(g: PushStateGossipV1): { frame: Uint8Array; kept: number; total: number } {
|
|
226
|
+
const total = g.replayBuffer.entries.length;
|
|
227
|
+
const full = encodePushStateGossipV1(g, NO_FRAME_LIMIT);
|
|
228
|
+
if (full.length <= this.maxBytes) {
|
|
229
|
+
return { frame: full, kept: total, total };
|
|
230
|
+
}
|
|
231
|
+
// Largest most-recent-N replay slice whose frame still fits. `encodeSlice(g, 0)` (dedupe window only)
|
|
232
|
+
// is the floor we ship if even that overruns the bound (pathological — the dedupe window is small).
|
|
233
|
+
let lo = 0;
|
|
234
|
+
let hi = total;
|
|
235
|
+
let bestN = 0;
|
|
236
|
+
let bestFrame = this.encodeSlice(g, 0);
|
|
237
|
+
while (lo <= hi) {
|
|
238
|
+
const mid = (lo + hi) >>> 1;
|
|
239
|
+
const frame = this.encodeSlice(g, mid);
|
|
240
|
+
if (frame.length <= this.maxBytes) {
|
|
241
|
+
bestN = mid;
|
|
242
|
+
bestFrame = frame;
|
|
243
|
+
lo = mid + 1;
|
|
244
|
+
} else {
|
|
245
|
+
hi = mid - 1;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { frame: bestFrame, kept: bestN, total };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Encode `g` with only its most-recent `keep` replay entries retained (the full dedupe window is kept). */
|
|
252
|
+
private encodeSlice(g: PushStateGossipV1, keep: number): Uint8Array {
|
|
253
|
+
const entries = g.replayBuffer.entries;
|
|
254
|
+
const kept = keep >= entries.length ? entries : entries.slice(entries.length - keep);
|
|
255
|
+
const sliced: PushStateGossipV1 = {
|
|
256
|
+
...g,
|
|
257
|
+
replayBuffer: { capacity: g.replayBuffer.capacity, entries: kept },
|
|
258
|
+
};
|
|
259
|
+
return encodePushStateGossipV1(sliced, NO_FRAME_LIMIT);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Register the inbound push-state-gossip protocol handler: read one bounded frame and hand it to
|
|
265
|
+
* {@link ReactivityPushStateGossipDriver.deliver} (which decodes, membership-gates, and merges), then close.
|
|
266
|
+
* One-way — no reply frame, matching the cohort-topic gossip handlers. A read error aborts the stream and
|
|
267
|
+
* {@link ReactivityPushStateGossipDriver.deliver} swallows a decode/forge/foreign failure, so the handler
|
|
268
|
+
* never throws on the stream. Mirrors {@link import("./notify-transport.js").registerNotifyHandler}.
|
|
269
|
+
*/
|
|
270
|
+
export function registerPushStateGossipHandler(
|
|
271
|
+
node: Libp2p,
|
|
272
|
+
protocol: string,
|
|
273
|
+
driver: Pick<ReactivityPushStateGossipDriver, "deliver">,
|
|
274
|
+
maxBytes = DEFAULT_STREAM_MAX_BYTES,
|
|
275
|
+
): void {
|
|
276
|
+
void node.handle(protocol, (stream: Stream, connection: Connection) => {
|
|
277
|
+
void (async (): Promise<void> => {
|
|
278
|
+
try {
|
|
279
|
+
const frame = await readFramed(stream, maxBytes);
|
|
280
|
+
driver.deliver(connection.remotePeer.toString(), frame);
|
|
281
|
+
await stream.close();
|
|
282
|
+
} catch {
|
|
283
|
+
try {
|
|
284
|
+
stream.abort(new Error("reactivity push-state gossip stream handler error"));
|
|
285
|
+
} catch {
|
|
286
|
+
/* already aborted */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
})();
|
|
290
|
+
});
|
|
291
|
+
}
|
|
@@ -369,8 +369,12 @@ function serveResumeReply(deps: RecoverServeDeps, req: ResumeV1, now: number): U
|
|
|
369
369
|
|
|
370
370
|
/**
|
|
371
371
|
* Build the recover serve callback for {@link handleRequestResponse}: it returns the reply frame, or
|
|
372
|
-
* `undefined`
|
|
373
|
-
*
|
|
372
|
+
* `undefined` on a decode/verify/replay/resolve failure. It never throws out of the handler.
|
|
373
|
+
*
|
|
374
|
+
* NOTE: `undefined` reaches the dialer as an explicit zero-length frame, and
|
|
375
|
+
* {@link Libp2pReactivityRecoverTransport.exchange} decodes that as a *terminal* protocol error rather
|
|
376
|
+
* than falling through to the next cohort candidate — one member declining ends the whole recover walk.
|
|
377
|
+
* Tracked as `debt-stream-reply-no-result-untyped`.
|
|
374
378
|
*/
|
|
375
379
|
export function createRecoverRequestHandler(deps: RecoverServeDeps): (frame: Uint8Array, fromPeer: PeerId) => Promise<Uint8Array | undefined> {
|
|
376
380
|
const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
|
|
@@ -394,7 +398,7 @@ export function createRecoverRequestHandler(deps: RecoverServeDeps): (frame: Uin
|
|
|
394
398
|
return Promise.resolve(serveResumeReply(deps, r, now));
|
|
395
399
|
} catch (err) {
|
|
396
400
|
// A malformed/foreign request (decode failure, foreign collectionId from serve*) must never throw
|
|
397
|
-
// out of the stream handler: log + no reply (
|
|
401
|
+
// out of the stream handler: log + no reply (a zero-length reply frame; see the note above).
|
|
398
402
|
log("recover serve: dropping request (no reply): %o", err);
|
|
399
403
|
return Promise.resolve(undefined);
|
|
400
404
|
}
|