@optimystic/db-p2p 0.25.1 → 0.27.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/dist/src/cluster/certified-claims.d.ts +17 -3
- package/dist/src/cluster/certified-claims.d.ts.map +1 -1
- package/dist/src/cluster/certified-claims.js +5 -3
- package/dist/src/cluster/certified-claims.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +57 -92
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +149 -201
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/commit-proof.d.ts +16 -0
- package/dist/src/cluster/commit-proof.d.ts.map +1 -1
- package/dist/src/cluster/commit-proof.js +32 -1
- package/dist/src/cluster/commit-proof.js.map +1 -1
- package/dist/src/cluster/quorum-restore.d.ts +81 -28
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +148 -51
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/race-resolution.d.ts +89 -0
- package/dist/src/cluster/race-resolution.d.ts.map +1 -0
- package/dist/src/cluster/race-resolution.js +140 -0
- package/dist/src/cluster/race-resolution.js.map +1 -0
- package/dist/src/cluster/reconcile-block.d.ts +9 -4
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
- package/dist/src/cluster/reconcile-block.js +28 -11
- package/dist/src/cluster/reconcile-block.js.map +1 -1
- package/dist/src/cluster/record-operations.d.ts +25 -0
- package/dist/src/cluster/record-operations.d.ts.map +1 -0
- package/dist/src/cluster/record-operations.js +56 -0
- package/dist/src/cluster/record-operations.js.map +1 -0
- package/dist/src/cohort-topic/host.d.ts +133 -51
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +290 -83
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +13 -4
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +5 -5
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +9 -0
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +13 -2
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +34 -2
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +57 -3
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/storage/block-latch.d.ts +5 -4
- package/dist/src/storage/block-latch.d.ts.map +1 -1
- package/dist/src/storage/block-latch.js +5 -4
- package/dist/src/storage/block-latch.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts +1 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +11 -3
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +17 -1
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/cached-raw-storage.js +8 -1
- package/dist/src/storage/cached-raw-storage.js.map +1 -1
- package/dist/src/storage/cached-store-driver.d.ts +7 -0
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
- package/dist/src/storage/cached-store-driver.js +20 -0
- package/dist/src/storage/cached-store-driver.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +45 -2
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-block-storage.js +29 -0
- package/dist/src/storage/i-block-storage.js.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +16 -0
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.d.ts +5 -1
- package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.js +8 -1
- package/dist/src/storage/kv-raw-storage.js.map +1 -1
- package/dist/src/storage/raw-store-driver.d.ts +15 -0
- package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +5 -0
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.js +6 -1
- package/dist/src/storage/shared-cache-pool.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +141 -99
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/storage/with-read-cache.d.ts +9 -5
- package/dist/src/storage/with-read-cache.d.ts.map +1 -1
- package/dist/src/storage/with-read-cache.js +16 -6
- package/dist/src/storage/with-read-cache.js.map +1 -1
- package/package.json +2 -2
- package/{README.md → readme.md} +2 -2
- package/src/cluster/certified-claims.ts +22 -9
- package/src/cluster/cluster-repo.ts +199 -212
- package/src/cluster/commit-proof.ts +38 -2
- package/src/cluster/quorum-restore.ts +183 -56
- package/src/cluster/race-resolution.ts +158 -0
- package/src/cluster/reconcile-block.ts +34 -11
- package/src/cluster/record-operations.ts +59 -0
- package/src/cohort-topic/host.ts +423 -118
- package/src/libp2p-node-base.ts +18 -9
- package/src/repo/cluster-coordinator.ts +1039 -1027
- package/src/repo/coordinator-repo.ts +1937 -1855
- package/src/storage/block-latch.ts +5 -4
- package/src/storage/block-storage.ts +11 -3
- package/src/storage/cached-raw-storage.ts +21 -1
- package/src/storage/cached-store-driver.ts +23 -0
- package/src/storage/i-block-storage.ts +47 -2
- package/src/storage/i-raw-storage.ts +17 -0
- package/src/storage/kv-raw-storage.ts +8 -1
- package/src/storage/raw-store-driver.ts +26 -0
- package/src/storage/shared-cache-pool.ts +6 -1
- package/src/storage/storage-repo.ts +143 -103
- package/src/storage/with-read-cache.ts +16 -6
|
@@ -162,12 +162,25 @@ export async function createCohortTopicHost(node, fret, options = {}) {
|
|
|
162
162
|
const members = cohortAround(coord).members.map(bytesToPeerIdString);
|
|
163
163
|
return members.includes(bytesToPeerIdString(fromBytes));
|
|
164
164
|
};
|
|
165
|
+
// Node-level `promote`-handler anti-abuse gate (`cohort-topic-promote-handler-verify-amplification`):
|
|
166
|
+
// a per-(peer, topic) rate limiter (own instance — the register-path limiter is per-coord inside each
|
|
167
|
+
// engine; this handler is node-level) plus the per-(coord, tier, topic) adopted-transition record.
|
|
168
|
+
// Defaults to `register_rate_per_peer` (4 / min / peer / topic) with exponential back-off. Declared here
|
|
169
|
+
// rather than with the other anti-DoS wiring below because `broadcastNotice` writes the record on the
|
|
170
|
+
// origination path.
|
|
171
|
+
const promoteGate = createPromoteGate(options.antiDos?.rateLimiter);
|
|
165
172
|
// --- outbound notice broadcast (gap 4) ---
|
|
166
173
|
// A coord engine that threshold-signs a promotion/demotion notice hands it here; we fan it over the
|
|
167
174
|
// `promote` protocol to the cohort around the served coord (siblings adopt the state) and, for a
|
|
168
175
|
// demotion, additionally to the parent coord (childCohortCount bookkeeping). Reuses the gossip
|
|
169
176
|
// transport's cohort peer resolution.
|
|
170
177
|
const broadcastNotice = (notice, servedCoord) => {
|
|
178
|
+
// Record the origination in the node-level adopted-transition map BEFORE fanning out: `broadcastOver`
|
|
179
|
+
// excludes self, so an originated notice never arrives back on the inbound path — this is the ONLY
|
|
180
|
+
// write for locally-originated transitions. Keyed off the notice's own `cohortCoord` only; the parent
|
|
181
|
+
// coord a demotion also fans to is deliberately outside this ordering (the parent-unlink is ordered by
|
|
182
|
+
// the child registry's per-child `lastEffectiveAt` — see `applyDemotionUnlinkAtParent`).
|
|
183
|
+
recordAdoptedTransition(promoteGate, notice);
|
|
171
184
|
const frame = encodeCohortMessage(notice, maxBytes);
|
|
172
185
|
// A demotion fans to BOTH the demoting child's served coord (siblings adopt `promoted = false` via the
|
|
173
186
|
// `cohortCoord`-routed apply) and the parent coord (the parent unrecords the child). `handleInboundNotice`
|
|
@@ -201,11 +214,6 @@ export async function createCohortTopicHost(node, fret, options = {}) {
|
|
|
201
214
|
// {@link createBootstrapEvidencePolicy}.
|
|
202
215
|
const hasCommittedParentBacking = options.antiDos?.parentTopicView !== undefined || options.committedParentTopicReader !== undefined;
|
|
203
216
|
const bootstrapEvidence = createBootstrapEvidencePolicy(options.antiDos, hash, log, parentTopicView, hasCommittedParentBacking);
|
|
204
|
-
// Node-level `promote`-handler anti-abuse gate (`cohort-topic-promote-handler-verify-amplification`):
|
|
205
|
-
// a per-(peer, topic) rate limiter (own instance — the register-path limiter is per-coord inside each
|
|
206
|
-
// engine; this handler is node-level) plus the per-(topic, tier) effectiveAt high-water. Defaults to
|
|
207
|
-
// `register_rate_per_peer` (4 / min / peer / topic) with exponential back-off.
|
|
208
|
-
const promoteGate = createPromoteGate(options.antiDos?.rateLimiter);
|
|
209
217
|
const ctx = {
|
|
210
218
|
hash,
|
|
211
219
|
addressing,
|
|
@@ -244,13 +252,19 @@ export async function createCohortTopicHost(node, fret, options = {}) {
|
|
|
244
252
|
// cohort it stops republishing and its anchor goes `"unknown"`, so it can be stranded distrusting the
|
|
245
253
|
// coord's later-epoch messages. The verifier self-heals via bounded re-TOFU on a demonstrated chain gap
|
|
246
254
|
// (`staleGapRecoveryStrikes`, see `db-core/.../membership/verifier.ts` + `docs/cohort-topic.md`
|
|
247
|
-
// §Bootstrapping trust).
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
// demotion and prefer it over (or alongside) the strike-counter heuristic.
|
|
255
|
+
// §Bootstrapping trust). Engine EVICTION drops the lock for the evicted coord via `onEngineEvicted`
|
|
256
|
+
// below — on a keyed node `pumpMembership` publishes for every engine (record-less ones included), so
|
|
257
|
+
// eviction cannot assume "no records → never published". Demotion, however, does NOT release the engine
|
|
258
|
+
// (a demoted engine keeps its records/forwarder and stays resident), so the demotion-side lock drop
|
|
259
|
+
// still has no signal to hang off; the strike-counter heuristic remains the recovery there.
|
|
253
260
|
onCertPublished: (cert) => verifier.cache(cert),
|
|
261
|
+
// Drop the verifier trust-lock for an evicted engine's coord — unconditional, because on a keyed node
|
|
262
|
+
// ANY engine (even one holding no records) may have published a cert via the gossip-cadence
|
|
263
|
+
// `pumpMembership` sweep. A no-op for a coord the verifier holds nothing for.
|
|
264
|
+
onEngineEvicted: (coord) => verifier.forget(bytesToB64url(coord)),
|
|
265
|
+
// Node-level adopted-transition reader (engine seeding). Wired unconditionally — key-less hosts also
|
|
266
|
+
// adopt verified inbound notices, so their recreated engines need the seed just the same.
|
|
267
|
+
adoptedTransition: (coord, tier, topicId) => promoteGate.transitions.get(transitionKey(bytesToB64url(coord), tier, bytesToB64url(topicId))),
|
|
254
268
|
};
|
|
255
269
|
const registry = createCoordRegistry(ctx, options.antiDos?.coordEnginesMax);
|
|
256
270
|
// --- cold-sibling engine instantiation on a verified co-member gossip frame (§Cold-start instantiation) ---
|
|
@@ -273,10 +287,11 @@ export async function createCohortTopicHost(node, fret, options = {}) {
|
|
|
273
287
|
// link work — so a tier-`d > 0` frame for an unknown coord falls through to today's drop (the bus has no
|
|
274
288
|
// engine subscribed to it). See `docs/cohort-topic.md` §Cold-start instantiation.
|
|
275
289
|
//
|
|
276
|
-
// The registry is hard-capped with LRU eviction
|
|
277
|
-
// gossip-instantiated cold sibling is no longer a permanent per-co-member-coord cost:
|
|
278
|
-
// reclaimed under memory pressure like any other cold engine, and a creation over a
|
|
279
|
-
// is refused (`CoordEngineRegistryFullError`) and dropped here rather than
|
|
290
|
+
// The registry is hard-capped with ranked LRU eviction (`createCoordRegistry`), so a
|
|
291
|
+
// gossip-instantiated cold sibling is no longer a permanent per-co-member-coord cost: one holding no
|
|
292
|
+
// state is reclaimed under memory pressure like any other cold engine, and a creation over a
|
|
293
|
+
// full-of-pinned registry is refused (`CoordEngineRegistryFullError`) and dropped here rather than
|
|
294
|
+
// crashing the gossip handler.
|
|
280
295
|
const maybeInstantiateColdSibling = (frame) => {
|
|
281
296
|
if (verifyGossip === undefined) {
|
|
282
297
|
return; // key-less / interim mode: no co-member gate, so never auto-instantiate
|
|
@@ -731,7 +746,7 @@ function clampTier(tier) {
|
|
|
731
746
|
export const DEFAULT_COORD_ENGINES_MAX = 2048;
|
|
732
747
|
/**
|
|
733
748
|
* Thrown by {@link CoordRegistry.forCoord} when it must create a new engine but the registry is full of
|
|
734
|
-
* **
|
|
749
|
+
* **pinned** engines (every slot holds records or a cold-start forwarder, so nothing is evictable).
|
|
735
750
|
* Signals a capacity refusal, not a bug — the register / child-link / cold-sibling dispatch paths catch it
|
|
736
751
|
* and answer a clean refusal (`unwilling_cohort` / `rejected` / drop) rather than letting it escape.
|
|
737
752
|
*/
|
|
@@ -741,20 +756,46 @@ export class CoordEngineRegistryFullError extends Error {
|
|
|
741
756
|
this.name = "CoordEngineRegistryFullError";
|
|
742
757
|
}
|
|
743
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Eviction rank per {@link EngineStateKind}. `"pinned"` — an engine holding it is not an eviction
|
|
761
|
+
* candidate at all. A number — the engine IS a candidate, but only after every candidate whose rank is
|
|
762
|
+
* strictly lower; higher = evicted later. An engine holding nothing is rank 0: the genuinely-cold
|
|
763
|
+
* attacker-sprayed coord the cap exists for.
|
|
764
|
+
*
|
|
765
|
+
* `children` / `promotion` are deliberately NOT pinned. A child link is peer-supplied input —
|
|
766
|
+
* key-less-permissive mode records one without any signature check — so pinning on it would let any peer
|
|
767
|
+
* make all `coordEnginesMax` slots un-evictable and drive {@link CoordEngineRegistryFullError} for
|
|
768
|
+
* legitimate coords, reopening the exact spray vector the cap closes. Ranking keeps the hard guarantee
|
|
769
|
+
* (some engine is always evictable while any unpinned one exists) while making the realistic loss — a
|
|
770
|
+
* handful of real parent/promoted engines among ~2000 attacker-cold ones — the *last* to go instead of a
|
|
771
|
+
* pure LRU-age pick.
|
|
772
|
+
*
|
|
773
|
+
* EXHAUSTIVE: a new {@link EngineStateKind} with no rank here does not typecheck, and the spec's
|
|
774
|
+
* exhaustiveness guard cross-checks this table's keys against a `liveness()` result at runtime.
|
|
775
|
+
*/
|
|
776
|
+
export const EVICTION_RANK = {
|
|
777
|
+
records: "pinned",
|
|
778
|
+
forwarders: "pinned",
|
|
779
|
+
children: 1,
|
|
780
|
+
promotion: 1,
|
|
781
|
+
};
|
|
744
782
|
/**
|
|
745
783
|
* Build the lazy `servedCoord → CoordEngine` registry over the shared collaborators, hard-capped at
|
|
746
|
-
* `maxEngines` with least-recently-used eviction
|
|
784
|
+
* `maxEngines` with ranked least-recently-used eviction.
|
|
747
785
|
*
|
|
748
786
|
* The served coord is a hash over attacker-chosen `(treeTier, participantCoord, topicId)`, and `forCoord`
|
|
749
787
|
* runs on the register hot path **before** the per-coord anti-DoS gates — so, uncapped, one peer spraying
|
|
750
788
|
* distinct coords drives unbounded engine allocation (each engine owns a store, gossip bus, rate limiter,
|
|
751
789
|
* replay guard, topic budget, …). The cap bounds that: on a creation over a full registry we evict the
|
|
752
|
-
* least
|
|
753
|
-
* tear it down; when every slot holds a
|
|
754
|
-
* so a legitimate multi-cohort node keeps working while
|
|
790
|
+
* `(rank, recency)`-least candidate engine ({@link EVICTION_RANK} over {@link CoordEngine.liveness}) and
|
|
791
|
+
* tear it down; when every slot holds a pinned engine (records or a forwarder) we refuse the new coord
|
|
792
|
+
* ({@link CoordEngineRegistryFullError}) so a legitimate multi-cohort node keeps working while
|
|
793
|
+
* attacker-driven cold engines cannot pile up.
|
|
755
794
|
*
|
|
756
795
|
* Recency is bumped on every lookup that hands back an engine (`forCoord` / `findByCoord` / `findHolder` /
|
|
757
|
-
* `findServing`), so a hot cohort under load
|
|
796
|
+
* `findServing`) and breaks ties *within* a rank, so a hot cohort under load loses only to a colder engine
|
|
797
|
+
* of the same rank — but rank is applied first, so even a hot rank-0 engine is evicted before any rank-1
|
|
798
|
+
* one (intended: a child-/promotion-holding engine survives a spray of freshly-touched cold coords).
|
|
758
799
|
*/
|
|
759
800
|
function createCoordRegistry(ctx, maxEngines = DEFAULT_COORD_ENGINES_MAX) {
|
|
760
801
|
if (!Number.isInteger(maxEngines) || maxEngines <= 0) {
|
|
@@ -766,27 +807,48 @@ function createCoordRegistry(ctx, maxEngines = DEFAULT_COORD_ENGINES_MAX) {
|
|
|
766
807
|
const recency = new Map();
|
|
767
808
|
let seq = 0;
|
|
768
809
|
const touch = (key) => { recency.set(key, ++seq); };
|
|
769
|
-
// An engine
|
|
770
|
-
//
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
810
|
+
// An engine's eviction rank: `undefined` when it holds any pinned state class (not a candidate at all),
|
|
811
|
+
// else the maximum numeric rank over the classes it holds (0 when it holds nothing). Iterates the rank
|
|
812
|
+
// table, whose keys the type system pins to exactly the EngineStateKind set the liveness record carries.
|
|
813
|
+
const evictionRank = (engine) => {
|
|
814
|
+
const liveness = engine.liveness();
|
|
815
|
+
let rank = 0;
|
|
816
|
+
for (const kind of Object.keys(EVICTION_RANK)) {
|
|
817
|
+
if (!liveness[kind]) {
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const r = EVICTION_RANK[kind];
|
|
821
|
+
if (r === "pinned") {
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
if (r > rank) {
|
|
825
|
+
rank = r;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return rank;
|
|
829
|
+
};
|
|
830
|
+
// Evict the `(rank, recency)`-lexicographically-least candidate engine to free a slot; returns true iff
|
|
831
|
+
// one was freed. Tears the victim down (`close()` drops its gossip-bus subscription) so eviction does not
|
|
832
|
+
// leak the subscription, and fires `ctx.onEngineEvicted` so the host drops the coord's verifier
|
|
833
|
+
// trust-lock.
|
|
774
834
|
//
|
|
775
|
-
// NOTE (verifier trust-lock
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
|
|
781
|
-
const evictOneIdle = () => {
|
|
835
|
+
// NOTE (verifier trust-lock): eviction can reclaim an engine that HAS published a membership cert — on a
|
|
836
|
+
// keyed node the gossip-cadence driver calls `pumpMembership` for every engine in `registry.all()`,
|
|
837
|
+
// record-less ones included, so "no records" never implied "never published". `onEngineEvicted` is
|
|
838
|
+
// therefore called for EVERY victim, unconditionally (the host wires it to `verifier.forget(coord)`, a
|
|
839
|
+
// cheap no-op for a coord that never published); do not try to track which engines published.
|
|
840
|
+
const evictOne = () => {
|
|
782
841
|
let victimKey;
|
|
842
|
+
let victimRank = Infinity;
|
|
783
843
|
let victimSeq = Infinity;
|
|
784
844
|
for (const [key, engine] of engines) {
|
|
785
|
-
|
|
786
|
-
|
|
845
|
+
const rank = evictionRank(engine);
|
|
846
|
+
if (rank === undefined) {
|
|
847
|
+
continue; // pinned: holds records or a mid-link cold-start forwarder — never evicted
|
|
787
848
|
}
|
|
788
849
|
const s = recency.get(key) ?? 0;
|
|
789
|
-
if (s < victimSeq) {
|
|
850
|
+
if (rank < victimRank || (rank === victimRank && s < victimSeq)) {
|
|
851
|
+
victimRank = rank;
|
|
790
852
|
victimSeq = s;
|
|
791
853
|
victimKey = key;
|
|
792
854
|
}
|
|
@@ -794,9 +856,11 @@ function createCoordRegistry(ctx, maxEngines = DEFAULT_COORD_ENGINES_MAX) {
|
|
|
794
856
|
if (victimKey === undefined) {
|
|
795
857
|
return false;
|
|
796
858
|
}
|
|
797
|
-
engines.get(victimKey)
|
|
859
|
+
const victim = engines.get(victimKey);
|
|
860
|
+
victim.close();
|
|
798
861
|
engines.delete(victimKey);
|
|
799
862
|
recency.delete(victimKey);
|
|
863
|
+
ctx.onEngineEvicted?.(victim.servedCoord);
|
|
800
864
|
return true;
|
|
801
865
|
};
|
|
802
866
|
return {
|
|
@@ -806,8 +870,8 @@ function createCoordRegistry(ctx, maxEngines = DEFAULT_COORD_ENGINES_MAX) {
|
|
|
806
870
|
// share one engine rather than racing to construct a second.
|
|
807
871
|
let engine = engines.get(key);
|
|
808
872
|
if (engine === undefined) {
|
|
809
|
-
if (engines.size >= maxEngines && !
|
|
810
|
-
// Full of
|
|
873
|
+
if (engines.size >= maxEngines && !evictOne()) {
|
|
874
|
+
// Full of pinned engines — refuse rather than grow unbounded. Callers turn this into a clean
|
|
811
875
|
// capacity reply/drop (see the paths listed on `CoordEngineRegistryFullError`).
|
|
812
876
|
log("cohort-topic: coord-engine registry full (max=%d) — refusing new coord %s", maxEngines, key);
|
|
813
877
|
throw new CoordEngineRegistryFullError(maxEngines);
|
|
@@ -907,6 +971,10 @@ class RotationState {
|
|
|
907
971
|
return undefined;
|
|
908
972
|
}
|
|
909
973
|
}
|
|
974
|
+
// NOTE: released children are kept forever as `linked = false` tombstones (the high-water that stops a stale
|
|
975
|
+
// link resurrecting them), so a long-lived engine's map grows with child churn. They cost memory only, never
|
|
976
|
+
// traffic — `linkedChildren` excludes them, so re-advertisement volume is bounded by the *live* child count.
|
|
977
|
+
// If child churn on a long-lived parent ever makes this map large, age tombstones out past a demotion horizon.
|
|
910
978
|
function createChildRegistry() {
|
|
911
979
|
const byTopic = new Map();
|
|
912
980
|
// Freshness-ordered write shared by link/unlink: apply `linked` only if `effectiveAt` is strictly newer than
|
|
@@ -924,7 +992,7 @@ function createChildRegistry() {
|
|
|
924
992
|
if (existing === undefined) {
|
|
925
993
|
// A never-seen unlink writes a `linked = false` tombstone (never a negative count), so a later stale
|
|
926
994
|
// link with an earlier effectiveAt cannot resurrect a demoted child.
|
|
927
|
-
children.set(childKey, { linked, lastEffectiveAt: effectiveAt });
|
|
995
|
+
children.set(childKey, { linked, lastEffectiveAt: effectiveAt, topicId, childCohortCoord });
|
|
928
996
|
return true;
|
|
929
997
|
}
|
|
930
998
|
if (effectiveAt <= existing.lastEffectiveAt) {
|
|
@@ -955,6 +1023,27 @@ function createChildRegistry() {
|
|
|
955
1023
|
}
|
|
956
1024
|
return n;
|
|
957
1025
|
},
|
|
1026
|
+
hasLinkedChildren() {
|
|
1027
|
+
for (const children of byTopic.values()) {
|
|
1028
|
+
for (const entry of children.values()) {
|
|
1029
|
+
if (entry.linked) {
|
|
1030
|
+
return true;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return false;
|
|
1035
|
+
},
|
|
1036
|
+
linkedChildren() {
|
|
1037
|
+
const out = [];
|
|
1038
|
+
for (const children of byTopic.values()) {
|
|
1039
|
+
for (const entry of children.values()) {
|
|
1040
|
+
if (entry.linked) {
|
|
1041
|
+
out.push({ topicId: entry.topicId, childCohortCoord: entry.childCohortCoord, effectiveAt: entry.lastEffectiveAt });
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return out;
|
|
1046
|
+
},
|
|
958
1047
|
};
|
|
959
1048
|
}
|
|
960
1049
|
/**
|
|
@@ -1111,6 +1200,15 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
|
|
|
1111
1200
|
return undefined;
|
|
1112
1201
|
}
|
|
1113
1202
|
};
|
|
1203
|
+
// Set by `close()` (registry eviction, or host `stop()`); makes every time-driven entry point below inert.
|
|
1204
|
+
// The gossip-cadence driver iterates a `registry.all()` SNAPSHOT and awaits between engines, so an inbound
|
|
1205
|
+
// register / child-link / cold-sibling frame landing in one of those awaits can evict — and close — an
|
|
1206
|
+
// engine the tick is still walking. Driving a closed engine afterwards is not merely wasted work: a
|
|
1207
|
+
// `pumpMembership` would publish a cert and re-run `onCertPublished` → `verifier.cache`, re-locking the very
|
|
1208
|
+
// coord `onEngineEvicted` → `verifier.forget` had just released, for a coord this node no longer serves.
|
|
1209
|
+
// Guarding here rather than in the driver keeps the invariant total: a closed engine does nothing, whoever
|
|
1210
|
+
// still holds a reference to it.
|
|
1211
|
+
let closed = false;
|
|
1114
1212
|
/**
|
|
1115
1213
|
* Publish (or refresh) this cohort's membership cert, attaching a rotation attestation when the cohort
|
|
1116
1214
|
* identity (epoch) changed since the last publish. `refresh` selects the publisher path: `false` for a
|
|
@@ -1121,7 +1219,7 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
|
|
|
1121
1219
|
* tick. Key-less interim mode no-ops (the verify-only signer cannot assemble).
|
|
1122
1220
|
*/
|
|
1123
1221
|
const publishMembership = async (now, refresh) => {
|
|
1124
|
-
if (!canPublish) {
|
|
1222
|
+
if (closed || !canPublish) {
|
|
1125
1223
|
return undefined;
|
|
1126
1224
|
}
|
|
1127
1225
|
const snapshot = snapshotAt(now); // also observes the current identity (snapshotAt → cohort())
|
|
@@ -1168,6 +1266,11 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
|
|
|
1168
1266
|
cohortCoord: () => servedCoord,
|
|
1169
1267
|
cohortEpoch: localEpoch,
|
|
1170
1268
|
signer: noticeSigner,
|
|
1269
|
+
// Seed a topic's replay ordering + direction from the node-level adopted-transition record (the
|
|
1270
|
+
// promote gate's map), which outlives this engine — so recreating an evicted engine neither reopens
|
|
1271
|
+
// the replay window nor forgets a correct promoted mode. For a notice at this served coord both a
|
|
1272
|
+
// promotion's `fromTier` and a demotion's `tier` equal `treeTier`, so this key matches both writes.
|
|
1273
|
+
seedTransition: (topicId) => ctx.adoptedTransition?.(servedCoord, treeTier, topicId),
|
|
1171
1274
|
// Production defaults (cap_promote = 64, …) unless the host was given a promotion override — the
|
|
1172
1275
|
// live-tier e2e lowers `capPromote` to drive promotion with a small participant count. The
|
|
1173
1276
|
// coord-derived inputs above (treeTier / childCohortCount / parentCoord) are never overridden.
|
|
@@ -1286,11 +1389,18 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
|
|
|
1286
1389
|
// NOTE: re-broadcasts willingness for every idle-but-willing cohort every T_willingness_heartbeat; if a
|
|
1287
1390
|
// node ever serves very many idle cohorts, batch the heartbeats or lengthen the interval.
|
|
1288
1391
|
let lastGossipAt;
|
|
1392
|
+
// Timestamp of the last round in which this engine re-advertised its linked child set, or `undefined` if it
|
|
1393
|
+
// never has. Bounds child-set resync to one frame per `T_willingness_heartbeat`, not one per gossip round.
|
|
1394
|
+
let lastChildReadvertAt;
|
|
1289
1395
|
// One gossip round: sweep stale records (firing the `evicted` deltas), freeze each resident topic's
|
|
1290
|
-
// traffic summary,
|
|
1291
|
-
// engine (no topics, no deltas) normally builds no
|
|
1292
|
-
// but willing engine still emits a willingness/load-
|
|
1396
|
+
// traffic summary, re-advertise the linked child set on its throttle, drain the touch/evicted/child deltas,
|
|
1397
|
+
// then assemble + sign + broadcast the frame. An idle engine (no topics, no deltas) normally builds no
|
|
1398
|
+
// frame — except a willingness heartbeat, where an idle but willing engine still emits a willingness/load-
|
|
1399
|
+
// only frame so a cold cohort can bootstrap.
|
|
1293
1400
|
const gossipRound = async (now) => {
|
|
1401
|
+
if (closed) {
|
|
1402
|
+
return undefined;
|
|
1403
|
+
}
|
|
1294
1404
|
engine.sweepStale(now);
|
|
1295
1405
|
const topicSummaries = residentTopics().map((topicId) => toCohortTopicSummary(topicId, traffic.publish(topicId, now), {
|
|
1296
1406
|
tier: tierOfTopic(store, topicId),
|
|
@@ -1301,6 +1411,34 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
|
|
|
1301
1411
|
// consistent cohort-wide, not a single-member shard.
|
|
1302
1412
|
childCohortCount: childRegistry.count(topicId),
|
|
1303
1413
|
}));
|
|
1414
|
+
// Child-set resync. A link/unlink delta is broadcast exactly once, so a member whose engine was
|
|
1415
|
+
// instantiated after a link drained (a parent rotation, or any FRET reshuffle adding a node to the
|
|
1416
|
+
// parent cohort) would read `childCohortCount == 0` for children that plainly exist — wrongly clearing
|
|
1417
|
+
// the demotion gate and telling seekers the topic is not promoted. So every `willingnessHeartbeatMs`
|
|
1418
|
+
// re-emit the *currently-linked* set at each child's original `effectiveAt`. Safe by construction: the
|
|
1419
|
+
// receiving merge is last-writer-wins on `effectiveAt`, so a re-advertisement is a no-op on a member
|
|
1420
|
+
// that already holds it and cannot resurrect a child released by a newer unlink. Queued through
|
|
1421
|
+
// `pending` (not straight into the frame) so a same-round *local* link for the same child collapses to
|
|
1422
|
+
// one ref instead of two (`queueChild` is last-writer-wins per `(topic, child)`). Sourcing from the
|
|
1423
|
+
// registry rather than the queue is what makes the unlink direction safe: an already-released child is
|
|
1424
|
+
// simply absent from `linkedChildren()`, and nothing between here and `drain()` awaits, so a
|
|
1425
|
+
// re-advertisement can never race a same-round unlink. Bounded to one extra frame per engine per
|
|
1426
|
+
// heartbeat, and the round is deliberately non-idle — the frame is the carrier.
|
|
1427
|
+
// NOTE: this heals a missed *link*, never a missed *unlink* — absence is not advertised, so a member
|
|
1428
|
+
// that dropped an unlink frame over-counts until it hears another delta for that child. Both consumers
|
|
1429
|
+
// fail conservatively there (demotion stays blocked; a seeker sweeps a no-longer-promoted tier —
|
|
1430
|
+
// wasteful, not wrong). If over-counting ever needs healing too, advertise the tombstones as well.
|
|
1431
|
+
if (lastChildReadvertAt === undefined || now - lastChildReadvertAt >= ctx.willingnessHeartbeatMs) {
|
|
1432
|
+
const linked = childRegistry.linkedChildren();
|
|
1433
|
+
for (const child of linked) {
|
|
1434
|
+
pending.childLink(child.topicId, child.childCohortCoord, child.effectiveAt);
|
|
1435
|
+
}
|
|
1436
|
+
// Only start the clock once something was actually enqueued: an engine that parents nothing stays
|
|
1437
|
+
// silent and re-advertises immediately when it first does hold a child.
|
|
1438
|
+
if (linked.length > 0) {
|
|
1439
|
+
lastChildReadvertAt = now;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1304
1442
|
const { records, evicted, childLinks, childUnlinks } = pending.drain();
|
|
1305
1443
|
const idle = topicSummaries.length === 0 && records.length === 0 && evicted.length === 0
|
|
1306
1444
|
&& childLinks.length === 0 && childUnlinks.length === 0;
|
|
@@ -1335,7 +1473,7 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
|
|
|
1335
1473
|
// parent coord) via the same path a promotion uses. Skipped without a key (verify-only signer can't
|
|
1336
1474
|
// assemble); for the single-cohort tier-0 milestone the lifecycle never demotes (the root has no parent).
|
|
1337
1475
|
const demotionTick = async (now) => {
|
|
1338
|
-
if (!canPublish) {
|
|
1476
|
+
if (closed || !canPublish) {
|
|
1339
1477
|
return;
|
|
1340
1478
|
}
|
|
1341
1479
|
for (const topicId of residentTopics()) {
|
|
@@ -1352,14 +1490,39 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
|
|
|
1352
1490
|
}
|
|
1353
1491
|
}
|
|
1354
1492
|
};
|
|
1493
|
+
// The exhaustive engine-owned-state census the registry's eviction ranking consults. One entry per
|
|
1494
|
+
// EngineStateKind — the total-Record type makes forgetting a new kind here a compile error. `children`
|
|
1495
|
+
// derives from the *linked* set (tombstones excluded), so a parent whose every child demoted away is
|
|
1496
|
+
// reclaimable again; `promotion` counts only adopted transition state (a `promoted` flag or a
|
|
1497
|
+
// `lastEffectiveAt` high-water — growth samples alone are rebuilt from the store and do not count).
|
|
1498
|
+
// NOTE: `evictOne` runs this once per resident engine per eviction — up to `coordEnginesMax` (2048)
|
|
1499
|
+
// censuses for one reclaim. Three of the four arms short-circuit and allocate nothing; `records` still
|
|
1500
|
+
// materializes every registration via `store.listAll()` only to read `.length` (pre-dating this census —
|
|
1501
|
+
// the old idle predicate did the same). Unmeasured, and bounded by a registry that is full of *cold*
|
|
1502
|
+
// engines in the case that matters. If eviction ever shows up in a profile, give `RegistrationStore` an
|
|
1503
|
+
// `isEmpty()` (its `byTopic` map already knows) and read that here.
|
|
1504
|
+
const liveness = () => ({
|
|
1505
|
+
records: store.listAll().length > 0,
|
|
1506
|
+
forwarders: coldStart.hasForwarders(),
|
|
1507
|
+
children: childRegistry.hasLinkedChildren(),
|
|
1508
|
+
promotion: promotion.hasAdoptedState(),
|
|
1509
|
+
});
|
|
1355
1510
|
return {
|
|
1356
1511
|
servedCoord,
|
|
1357
1512
|
treeTier,
|
|
1358
1513
|
engine,
|
|
1359
1514
|
cohort,
|
|
1360
1515
|
cohortIdentityAt: (epoch) => rotationState.membersAt(bytesToB64url(epoch)),
|
|
1361
|
-
|
|
1362
|
-
|
|
1516
|
+
liveness,
|
|
1517
|
+
// Derivations of `liveness()` — kept on the interface for existing callers/specs, but sourced from
|
|
1518
|
+
// the same census so the two views cannot drift.
|
|
1519
|
+
// NOTE: each call runs the FULL census (allocating one record, and touching the child registry and
|
|
1520
|
+
// promotion lifecycle) where it used to be a bare `store.listAll()` check. Free today: as of this
|
|
1521
|
+
// writing nothing in `src/` calls either accessor — the registry ranks via `liveness()` directly and
|
|
1522
|
+
// the only callers are specs. If a hot path (a per-frame dispatch gate, say) ever starts calling one
|
|
1523
|
+
// per message, give it a direct single-field read instead of routing it through the census.
|
|
1524
|
+
hasState: () => liveness().records,
|
|
1525
|
+
hasForwarders: () => liveness().forwarders,
|
|
1363
1526
|
holds: (topicId, participantId) => store.getByParticipant(topicId, participantId) !== undefined,
|
|
1364
1527
|
records: (topicId) => store.listByTopic(topicId),
|
|
1365
1528
|
topicTraffic: (topicId) => traffic.snapshot(topicId),
|
|
@@ -1400,7 +1563,10 @@ function createCoordEngine(ctx, servedCoord, treeTier, participantCoord) {
|
|
|
1400
1563
|
pumpMembership: (now) => publishMembership(now, true),
|
|
1401
1564
|
gossipRound,
|
|
1402
1565
|
demotionTick,
|
|
1403
|
-
close: () =>
|
|
1566
|
+
close: () => {
|
|
1567
|
+
closed = true;
|
|
1568
|
+
bus.close();
|
|
1569
|
+
},
|
|
1404
1570
|
};
|
|
1405
1571
|
}
|
|
1406
1572
|
/**
|
|
@@ -1624,14 +1790,53 @@ export async function handleSignRequest(request, fromPeerStr, deps) {
|
|
|
1624
1790
|
return { v: 1, signer: bytesToB64url(deps.selfMember), signature: bytesToB64url(signature) };
|
|
1625
1791
|
}
|
|
1626
1792
|
/**
|
|
1627
|
-
* Hard cap on tracked
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1793
|
+
* Hard cap on tracked adopted-transition entries; the least-recently-touched are evicted beyond this. A
|
|
1794
|
+
* modest bound is plenty — only adopted transitions grow the map — but it caps the otherwise
|
|
1795
|
+
* retain-forever shape on a long-lived node.
|
|
1796
|
+
*
|
|
1797
|
+
* NOTE: this counts `(coord, tier, topic)` triples, NOT engines, so it is not a per-engine guarantee: a
|
|
1798
|
+
* node at the {@link DEFAULT_COORD_ENGINES_MAX} (2048) registry cap that has transitioned more than ~4
|
|
1799
|
+
* topics per coord can overflow it, and the least-recently-touched entries go first. That is safe while
|
|
1800
|
+
* the owning engine is resident (its `lastEffectiveAt` still orders replays); if a node ever runs that
|
|
1801
|
+
* wide AND evicts engines, raise this cap rather than relying on the engine layer.
|
|
1630
1802
|
*/
|
|
1631
|
-
export const
|
|
1803
|
+
export const PROMOTE_TRANSITIONS_MAX_KEYS = 8192;
|
|
1632
1804
|
/** Build the default {@link PromoteGate} from the (optional) anti-DoS rate-limiter config. */
|
|
1633
1805
|
export function createPromoteGate(rateLimiterConfig) {
|
|
1634
|
-
return { rateLimiter: createRegisterRateLimiter(rateLimiterConfig),
|
|
1806
|
+
return { rateLimiter: createRegisterRateLimiter(rateLimiterConfig), transitions: new LruMap(PROMOTE_TRANSITIONS_MAX_KEYS) };
|
|
1807
|
+
}
|
|
1808
|
+
/** The {@link PromoteGate.transitions} key for one `(cohortCoord, tier, topicId)` (all b64url-encoded parts). */
|
|
1809
|
+
export function transitionKey(cohortCoordB64, tier, topicIdB64) {
|
|
1810
|
+
return `${cohortCoordB64}|${tier}|${topicIdB64}`;
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* The {@link transitionKey} a notice's own fields address: the served `cohortCoord` it was decided at, the
|
|
1814
|
+
* tier the deciding cohort serves the topic at (`fromTier` on a promotion, `tier` on a demotion — both the
|
|
1815
|
+
* engine's own tree tier for a notice at its served coord), and the topic. A demotion also fans to the
|
|
1816
|
+
* parent coord, but that parent-unlink path is deliberately outside this ordering — see
|
|
1817
|
+
* {@link applyDemotionUnlinkAtParent}.
|
|
1818
|
+
*/
|
|
1819
|
+
export function noticeTransitionKey(notice) {
|
|
1820
|
+
return transitionKey(notice.cohortCoord, "parentCohortCoord" in notice ? notice.tier : notice.fromTier, notice.topicId);
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Record `notice` as the last adopted transition for its `(coord, tier, topic)` — monotonic per key (an
|
|
1824
|
+
* older or equal `effectiveAt` never overwrites), with the direction taken from the notice's shape (a
|
|
1825
|
+
* promotion sets `promoted = true`, a demotion `false`). Called from BOTH adopt paths: the verified
|
|
1826
|
+
* inbound apply in {@link handleInboundNotice} and the host's `broadcastNotice` origination seam.
|
|
1827
|
+
*/
|
|
1828
|
+
export function recordAdoptedTransition(gate, notice) {
|
|
1829
|
+
// NOTE: this write is monotonic but `promote()` / `demote()` origination is not — the lifecycle stamps
|
|
1830
|
+
// `effectiveAt = now` without consulting its own `lastEffectiveAt`. A backwards local clock can therefore
|
|
1831
|
+
// originate a transition the record refuses, leaving the record's direction stale relative to the engine
|
|
1832
|
+
// until the next forward-clock transition. Harmless today (the wall clock only skews backwards on an NTP
|
|
1833
|
+
// step, and the same skew already perturbs every `effectiveAt`-ordered path); if origination ever gains a
|
|
1834
|
+
// monotonic clock, drop the divergence by ordering `promote()` / `demote()` against `lastEffectiveAt` too.
|
|
1835
|
+
const key = noticeTransitionKey(notice);
|
|
1836
|
+
const held = gate.transitions.get(key);
|
|
1837
|
+
if (held === undefined || notice.effectiveAt > held.effectiveAt) {
|
|
1838
|
+
gate.transitions.set(key, { effectiveAt: notice.effectiveAt, promoted: !("parentCohortCoord" in notice) });
|
|
1839
|
+
}
|
|
1635
1840
|
}
|
|
1636
1841
|
/**
|
|
1637
1842
|
* Decode a `promote`-protocol frame as a {@link PromotionNoticeV1} or {@link DemotionNoticeV1} (try one,
|
|
@@ -1726,11 +1931,12 @@ export async function verifyAndApplyNotice(inbound, target, verifier, now) {
|
|
|
1726
1931
|
* - `"unlinked"` — verified; the child was unrecorded (or was already released — the child registry's own
|
|
1727
1932
|
* per-`(topic, childCoord)` freshness makes a replay an idempotent no-op).
|
|
1728
1933
|
*
|
|
1729
|
-
* **Freshness is the child registry's, not the promote-gate
|
|
1730
|
-
* by the child coord and advanced only on a sibling-adopt `"applied"`; the unlink
|
|
1731
|
-
* the child registry's per-child `lastEffectiveAt`, so a demotion that is a stale
|
|
1732
|
-
* target still applies the unlink at the parent, and vice-versa. The verify
|
|
1733
|
-
* {@link PROMOTE_REFETCH_MIN_INTERVAL_MS} bound as the sibling-adopt, so it cannot amplify
|
|
1934
|
+
* **Freshness is the child registry's, not the promote-gate transition record.** The sibling-adopt record is
|
|
1935
|
+
* keyed by the child coord and, on the inbound path, advanced only on a sibling-adopt `"applied"`; the unlink
|
|
1936
|
+
* is ordered independently by the child registry's per-child `lastEffectiveAt`, so a demotion that is a stale
|
|
1937
|
+
* no-op for the sibling-adopt target still applies the unlink at the parent, and vice-versa. The verify
|
|
1938
|
+
* carries the same {@link PROMOTE_REFETCH_MIN_INTERVAL_MS} bound as the sibling-adopt, so it cannot amplify
|
|
1939
|
+
* into dials.
|
|
1734
1940
|
*/
|
|
1735
1941
|
export async function applyDemotionUnlinkAtParent(notice, registry, verifier, now) {
|
|
1736
1942
|
const parent = registry.findByCoord(b64urlToBytes(notice.parentCohortCoord));
|
|
@@ -1764,16 +1970,17 @@ export async function applyDemotionUnlinkAtParent(notice, registry, verifier, no
|
|
|
1764
1970
|
* work:
|
|
1765
1971
|
*
|
|
1766
1972
|
* ```
|
|
1767
|
-
* decode → per-(peer,topic) rate limit → resolve engine by carried cohortCoord →
|
|
1768
|
-
*
|
|
1973
|
+
* decode → per-(peer,topic) rate limit → resolve engine by carried cohortCoord → adopted-transition stale gate → verify+apply
|
|
1974
|
+
* ↘ (demotion) parent-unlink at parentCohortCoord
|
|
1769
1975
|
* ```
|
|
1770
1976
|
*
|
|
1771
1977
|
* A **demotion** carries a second, independent apply semantics: beyond the sibling-adopt above, it also
|
|
1772
1978
|
* releases the demoting child at its parent cohort ({@link applyDemotionUnlinkAtParent}). Both paths may fire
|
|
1773
1979
|
* on one node (one that serves both the child coord and the parent coord). The parent-unlink runs OUTSIDE the
|
|
1774
|
-
* sibling-adopt
|
|
1775
|
-
* frame after the child-coord frame advanced it); its freshness is the child registry's own
|
|
1776
|
-
* `"unlinked"` is returned when the unlink fired but the sibling-adopt did not (a parent-only
|
|
1980
|
+
* sibling-adopt transition record (which is keyed by the child coord and would otherwise stale-drop the
|
|
1981
|
+
* parent-coord frame after the child-coord frame advanced it); its freshness is the child registry's own
|
|
1982
|
+
* per-child key. `"unlinked"` is returned when the unlink fired but the sibling-adopt did not (a parent-only
|
|
1983
|
+
* node).
|
|
1777
1984
|
*
|
|
1778
1985
|
* - **Rate limit** (`gate.rateLimiter`) keys on `(from, topicId)`; an over-rate peer is dropped before the
|
|
1779
1986
|
* coord lookup and the verify, so a peer cannot amplify junk into verify/network work.
|
|
@@ -1781,10 +1988,11 @@ export async function applyDemotionUnlinkAtParent(notice, registry, verifier, no
|
|
|
1781
1988
|
* notice was decided for, covered by its signature. A node serving several sibling cohorts for one
|
|
1782
1989
|
* `(topic, tier)` applies the notice to the cohort that produced it, never a first-match `(topic, tier)`
|
|
1783
1990
|
* scan; a coord this node does not serve is dropped.
|
|
1784
|
-
* - **
|
|
1785
|
-
* at or below the last *
|
|
1786
|
-
*
|
|
1787
|
-
* Keying by coord
|
|
1991
|
+
* - **Stale gate** (`gate.transitions`, keyed per `(cohortCoord, tier, topicId)`) drops a notice whose
|
|
1992
|
+
* `effectiveAt` is at or below the last *adopted* transition — a replay / out-of-order frame — before
|
|
1993
|
+
* `verifyMessage`. On this inbound path it advances **only** on an `"applied"` outcome, so a forged frame
|
|
1994
|
+
* (which never verifies) cannot poison it. Keying by coord keeps two sibling cohorts on one node from
|
|
1995
|
+
* sharing an entry; keying by topic keeps two topics at one coord ordering independently.
|
|
1788
1996
|
* - The receiver-side `cohortEpoch` is intentionally **not** gated on: the epoch rotates on every
|
|
1789
1997
|
* membership change, so a legitimately in-flight notice can briefly carry the prior epoch right after a
|
|
1790
1998
|
* rotation — making an epoch check a brittle, false-positive-prone filter. The rate limiter + high-water
|
|
@@ -1820,21 +2028,20 @@ export async function handleInboundNotice(frame, from, registry, verifier, gate,
|
|
|
1820
2028
|
siblingOutcome = "dropped";
|
|
1821
2029
|
}
|
|
1822
2030
|
else {
|
|
1823
|
-
// Freshness / replay gate: drop
|
|
1824
|
-
//
|
|
1825
|
-
//
|
|
1826
|
-
//
|
|
1827
|
-
const
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
log("promote: stale %s notice for topic %s tier %d (effectiveAt %d <= high-water %d)", inbound.kind, inbound.notice.topicId, tier, inbound.notice.effectiveAt, water);
|
|
2031
|
+
// Freshness / replay gate: drop a notice at or below the last adopted transition for its
|
|
2032
|
+
// `(coord, tier, topic)` before the expensive verify. Keyed per coord (so two sibling cohorts on one
|
|
2033
|
+
// node never share an entry) AND per topic (so an applied notice for topic A never stale-drops a
|
|
2034
|
+
// legitimate topic-B notice at the same coord).
|
|
2035
|
+
const held = gate.transitions.get(noticeTransitionKey(inbound.notice));
|
|
2036
|
+
if (held !== undefined && inbound.notice.effectiveAt <= held.effectiveAt) {
|
|
2037
|
+
log("promote: stale %s notice for topic %s tier %d (effectiveAt %d <= last adopted %d)", inbound.kind, inbound.notice.topicId, tier, inbound.notice.effectiveAt, held.effectiveAt);
|
|
1831
2038
|
siblingOutcome = "stale";
|
|
1832
2039
|
}
|
|
1833
2040
|
else {
|
|
1834
2041
|
siblingOutcome = await verifyAndApplyNotice(inbound, target, verifier, now);
|
|
1835
2042
|
if (siblingOutcome === "applied") {
|
|
1836
|
-
//
|
|
1837
|
-
gate
|
|
2043
|
+
// Record only a *verified-and-applied* notice, so a forged frame cannot poison the ordering.
|
|
2044
|
+
recordAdoptedTransition(gate, inbound.notice);
|
|
1838
2045
|
}
|
|
1839
2046
|
else {
|
|
1840
2047
|
log("promote: %s %s notice for topic %s tier %d", siblingOutcome, inbound.kind, inbound.notice.topicId, tier);
|
|
@@ -1846,12 +2053,12 @@ export async function handleInboundNotice(frame, from, registry, verifier, gate,
|
|
|
1846
2053
|
}
|
|
1847
2054
|
// --- Parent-unlink path (demotion only): additionally release the demoting child at its parent cohort. ---
|
|
1848
2055
|
// A demotion is fanned to BOTH the child coord (sibling-adopt, above) and the parent coord (this unlink),
|
|
1849
|
-
// arriving as two independent frames. This path is deliberately OUTSIDE the sibling-adopt
|
|
1850
|
-
//
|
|
1851
|
-
// coords — the child-coord frame would advance
|
|
1852
|
-
// could unrecord. The child registry's own per-`(topic, childCoord)` freshness orders the unlink instead,
|
|
1853
|
-
// a replay is an idempotent no-op. The verify is against the child cohort cert (same as the
|
|
1854
|
-
// so a forged demotion cannot unrecord.
|
|
2056
|
+
// arriving as two independent frames. This path is deliberately OUTSIDE the sibling-adopt transition
|
|
2057
|
+
// record: that record is keyed by the child coord and advanced on a sibling-adopt apply, so — on a node
|
|
2058
|
+
// serving both coords — the child-coord frame would advance it and stale-drop the parent-coord frame before
|
|
2059
|
+
// it could unrecord. The child registry's own per-`(topic, childCoord)` freshness orders the unlink instead,
|
|
2060
|
+
// so a replay is an idempotent no-op. The verify is against the child cohort cert (same as the
|
|
2061
|
+
// sibling-adopt), so a forged demotion cannot unrecord.
|
|
1855
2062
|
const unlink = await applyDemotionUnlinkAtParent(inbound.notice, registry, verifier, now);
|
|
1856
2063
|
if (siblingOutcome === "applied") {
|
|
1857
2064
|
return "applied"; // a node serving both coords: the sibling-adopt is the primary reported outcome
|