@optimystic/db-core 0.14.1 → 0.16.3
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/blocks/block-store.d.ts +18 -1
- package/dist/src/blocks/block-store.d.ts.map +1 -1
- package/dist/src/blocks/helpers.d.ts +2 -2
- package/dist/src/blocks/helpers.d.ts.map +1 -1
- package/dist/src/blocks/helpers.js +2 -2
- package/dist/src/blocks/helpers.js.map +1 -1
- package/dist/src/btree/btree.d.ts +27 -6
- package/dist/src/btree/btree.d.ts.map +1 -1
- package/dist/src/btree/btree.js +104 -61
- package/dist/src/btree/btree.js.map +1 -1
- package/dist/src/chain/chain.d.ts.map +1 -1
- package/dist/src/chain/chain.js +11 -1
- package/dist/src/chain/chain.js.map +1 -1
- package/dist/src/cluster/index.d.ts +1 -0
- package/dist/src/cluster/index.d.ts.map +1 -1
- package/dist/src/cluster/index.js +1 -0
- package/dist/src/cluster/index.js.map +1 -1
- package/dist/src/cluster/membership.d.ts +49 -0
- package/dist/src/cluster/membership.d.ts.map +1 -0
- package/dist/src/cluster/membership.js +75 -0
- package/dist/src/cluster/membership.js.map +1 -0
- package/dist/src/cluster/structs.d.ts +50 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js +9 -1
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/cohort-topic/addressing.d.ts +8 -2
- package/dist/src/cohort-topic/addressing.d.ts.map +1 -1
- package/dist/src/cohort-topic/addressing.js +11 -3
- package/dist/src/cohort-topic/addressing.js.map +1 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts +13 -7
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts.map +1 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.js +17 -6
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.js.map +1 -1
- package/dist/src/cohort-topic/antidos/replay-guard.d.ts +17 -0
- package/dist/src/cohort-topic/antidos/replay-guard.d.ts.map +1 -1
- package/dist/src/cohort-topic/antidos/replay-guard.js +33 -0
- package/dist/src/cohort-topic/antidos/replay-guard.js.map +1 -1
- package/dist/src/cohort-topic/antidos/topic-budget.d.ts +7 -0
- package/dist/src/cohort-topic/antidos/topic-budget.d.ts.map +1 -1
- package/dist/src/cohort-topic/antidos/topic-budget.js +8 -2
- package/dist/src/cohort-topic/antidos/topic-budget.js.map +1 -1
- package/dist/src/cohort-topic/coldstart.d.ts +34 -14
- package/dist/src/cohort-topic/coldstart.d.ts.map +1 -1
- package/dist/src/cohort-topic/coldstart.js +25 -11
- package/dist/src/cohort-topic/coldstart.js.map +1 -1
- package/dist/src/cohort-topic/gossip/bus.d.ts +12 -1
- package/dist/src/cohort-topic/gossip/bus.d.ts.map +1 -1
- package/dist/src/cohort-topic/gossip/bus.js +27 -1
- package/dist/src/cohort-topic/gossip/bus.js.map +1 -1
- package/dist/src/cohort-topic/gossip/view.d.ts +8 -0
- package/dist/src/cohort-topic/gossip/view.d.ts.map +1 -1
- package/dist/src/cohort-topic/gossip/view.js +17 -1
- package/dist/src/cohort-topic/gossip/view.js.map +1 -1
- package/dist/src/cohort-topic/member-engine.d.ts +10 -3
- package/dist/src/cohort-topic/member-engine.d.ts.map +1 -1
- package/dist/src/cohort-topic/member-engine.js +29 -14
- package/dist/src/cohort-topic/member-engine.js.map +1 -1
- package/dist/src/cohort-topic/membership/publisher.d.ts +14 -7
- package/dist/src/cohort-topic/membership/publisher.d.ts.map +1 -1
- package/dist/src/cohort-topic/membership/publisher.js +15 -27
- package/dist/src/cohort-topic/membership/publisher.js.map +1 -1
- package/dist/src/cohort-topic/membership/verifier.d.ts +35 -0
- package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
- package/dist/src/cohort-topic/membership/verifier.js +88 -3
- package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
- package/dist/src/cohort-topic/promotion.d.ts +7 -0
- package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
- package/dist/src/cohort-topic/promotion.js +16 -2
- package/dist/src/cohort-topic/promotion.js.map +1 -1
- package/dist/src/cohort-topic/registration/renewal.d.ts +18 -0
- package/dist/src/cohort-topic/registration/renewal.d.ts.map +1 -1
- package/dist/src/cohort-topic/registration/renewal.js +54 -0
- package/dist/src/cohort-topic/registration/renewal.js.map +1 -1
- package/dist/src/cohort-topic/registration/types.d.ts +12 -0
- package/dist/src/cohort-topic/registration/types.d.ts.map +1 -1
- package/dist/src/cohort-topic/registration/types.js +14 -0
- package/dist/src/cohort-topic/registration/types.js.map +1 -1
- package/dist/src/cohort-topic/service.d.ts +4 -3
- package/dist/src/cohort-topic/service.d.ts.map +1 -1
- package/dist/src/cohort-topic/service.js +38 -12
- package/dist/src/cohort-topic/service.js.map +1 -1
- package/dist/src/cohort-topic/sig/payloads.d.ts +23 -7
- package/dist/src/cohort-topic/sig/payloads.d.ts.map +1 -1
- package/dist/src/cohort-topic/sig/payloads.js +22 -6
- package/dist/src/cohort-topic/sig/payloads.js.map +1 -1
- package/dist/src/cohort-topic/traffic.d.ts +6 -0
- package/dist/src/cohort-topic/traffic.d.ts.map +1 -1
- package/dist/src/cohort-topic/traffic.js +16 -1
- package/dist/src/cohort-topic/traffic.js.map +1 -1
- package/dist/src/cohort-topic/walk.d.ts +22 -7
- package/dist/src/cohort-topic/walk.d.ts.map +1 -1
- package/dist/src/cohort-topic/walk.js +78 -18
- package/dist/src/cohort-topic/walk.js.map +1 -1
- package/dist/src/cohort-topic/wire/codec.d.ts +3 -1
- package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/codec.js +7 -1
- package/dist/src/cohort-topic/wire/codec.js.map +1 -1
- package/dist/src/cohort-topic/wire/index.d.ts +1 -1
- package/dist/src/cohort-topic/wire/index.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/index.js +1 -1
- package/dist/src/cohort-topic/wire/index.js.map +1 -1
- package/dist/src/cohort-topic/wire/payloads.d.ts +5 -4
- package/dist/src/cohort-topic/wire/payloads.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/payloads.js +14 -5
- package/dist/src/cohort-topic/wire/payloads.js.map +1 -1
- package/dist/src/cohort-topic/wire/primitives.d.ts +65 -0
- package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -0
- package/dist/src/cohort-topic/wire/primitives.js +173 -0
- package/dist/src/cohort-topic/wire/primitives.js.map +1 -0
- package/dist/src/cohort-topic/wire/types.d.ts +132 -5
- package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.d.ts +15 -5
- package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.js +146 -123
- package/dist/src/cohort-topic/wire/validate.js.map +1 -1
- package/dist/src/collection/collection.d.ts +23 -12
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +111 -35
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collection/struct.d.ts +37 -2
- package/dist/src/collection/struct.d.ts.map +1 -1
- package/dist/src/collection/struct.js +16 -1
- package/dist/src/collection/struct.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +18 -1
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +36 -6
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/log/log.d.ts +26 -3
- package/dist/src/log/log.d.ts.map +1 -1
- package/dist/src/log/log.js +34 -7
- package/dist/src/log/log.js.map +1 -1
- package/dist/src/log/struct.d.ts +5 -4
- package/dist/src/log/struct.d.ts.map +1 -1
- package/dist/src/log/struct.js.map +1 -1
- package/dist/src/matchmaking/wire.d.ts +2 -1
- package/dist/src/matchmaking/wire.d.ts.map +1 -1
- package/dist/src/matchmaking/wire.js +13 -92
- package/dist/src/matchmaking/wire.js.map +1 -1
- package/dist/src/network/i-key-network.d.ts +9 -0
- package/dist/src/network/i-key-network.d.ts.map +1 -1
- package/dist/src/network/i-repo.d.ts +8 -0
- package/dist/src/network/i-repo.d.ts.map +1 -1
- package/dist/src/network/struct.d.ts +10 -0
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/reactivity/push-state.d.ts.map +1 -1
- package/dist/src/reactivity/push-state.js +4 -33
- package/dist/src/reactivity/push-state.js.map +1 -1
- package/dist/src/reactivity/wire-validate.d.ts +7 -22
- package/dist/src/reactivity/wire-validate.d.ts.map +1 -1
- package/dist/src/reactivity/wire-validate.js +7 -73
- package/dist/src/reactivity/wire-validate.js.map +1 -1
- package/dist/src/reactivity/wire.js +3 -3
- package/dist/src/reactivity/wire.js.map +1 -1
- package/dist/src/testing/async-wait.d.ts +23 -0
- package/dist/src/testing/async-wait.d.ts.map +1 -0
- package/dist/src/testing/async-wait.js +42 -0
- package/dist/src/testing/async-wait.js.map +1 -0
- package/dist/src/testing/index.d.ts +3 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/testing/index.js +3 -0
- package/dist/src/testing/index.js.map +1 -0
- package/dist/src/testing/test-transactor.d.ts +44 -0
- package/dist/src/testing/test-transactor.d.ts.map +1 -0
- package/dist/src/testing/test-transactor.js +423 -0
- package/dist/src/testing/test-transactor.js.map +1 -0
- package/dist/src/transaction/actions-engine.d.ts +6 -3
- package/dist/src/transaction/actions-engine.d.ts.map +1 -1
- package/dist/src/transaction/actions-engine.js +8 -7
- package/dist/src/transaction/actions-engine.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +43 -22
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +443 -178
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/errors.d.ts +82 -0
- package/dist/src/transaction/errors.d.ts.map +1 -0
- package/dist/src/transaction/errors.js +93 -0
- package/dist/src/transaction/errors.js.map +1 -0
- package/dist/src/transaction/index.d.ts +6 -3
- package/dist/src/transaction/index.d.ts.map +1 -1
- package/dist/src/transaction/index.js +4 -2
- package/dist/src/transaction/index.js.map +1 -1
- package/dist/src/transaction/operations-hash.d.ts +119 -0
- package/dist/src/transaction/operations-hash.d.ts.map +1 -0
- package/dist/src/transaction/operations-hash.js +180 -0
- package/dist/src/transaction/operations-hash.js.map +1 -0
- package/dist/src/transaction/read-dependency-collector.d.ts +43 -0
- package/dist/src/transaction/read-dependency-collector.d.ts.map +1 -0
- package/dist/src/transaction/read-dependency-collector.js +69 -0
- package/dist/src/transaction/read-dependency-collector.js.map +1 -0
- package/dist/src/transaction/session.d.ts +12 -3
- package/dist/src/transaction/session.d.ts.map +1 -1
- package/dist/src/transaction/session.js +35 -9
- package/dist/src/transaction/session.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +162 -1
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transaction/transaction.js +85 -3
- package/dist/src/transaction/transaction.js.map +1 -1
- package/dist/src/transaction/validator.d.ts +3 -11
- package/dist/src/transaction/validator.d.ts.map +1 -1
- package/dist/src/transaction/validator.js +69 -28
- package/dist/src/transaction/validator.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +35 -0
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +146 -40
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +19 -5
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +43 -11
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/atomic-proxy.d.ts +35 -5
- package/dist/src/transform/atomic-proxy.d.ts.map +1 -1
- package/dist/src/transform/atomic-proxy.js +39 -7
- package/dist/src/transform/atomic-proxy.js.map +1 -1
- package/dist/src/transform/cache-source.d.ts +36 -5
- package/dist/src/transform/cache-source.d.ts.map +1 -1
- package/dist/src/transform/cache-source.js +84 -8
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/helpers.d.ts.map +1 -1
- package/dist/src/transform/helpers.js +8 -2
- package/dist/src/transform/helpers.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +17 -2
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +66 -11
- package/dist/src/transform/tracker.js.map +1 -1
- package/dist/src/utility/backoff.d.ts +48 -0
- package/dist/src/utility/backoff.d.ts.map +1 -0
- package/dist/src/utility/backoff.js +60 -0
- package/dist/src/utility/backoff.js.map +1 -0
- package/dist/src/utility/pending.d.ts +1 -0
- package/dist/src/utility/pending.d.ts.map +1 -1
- package/dist/src/utility/pending.js +3 -1
- package/dist/src/utility/pending.js.map +1 -1
- package/package.json +4 -4
- package/src/blocks/block-store.ts +19 -1
- package/src/blocks/helpers.ts +3 -3
- package/src/btree/btree.ts +108 -63
- package/src/chain/chain.ts +11 -1
- package/src/cluster/index.ts +1 -0
- package/src/cluster/membership.ts +85 -0
- package/src/cluster/structs.ts +51 -1
- package/src/cohort-topic/addressing.ts +12 -4
- package/src/cohort-topic/antidos/bootstrap-evidence.ts +21 -9
- package/src/cohort-topic/antidos/replay-guard.ts +37 -0
- package/src/cohort-topic/antidos/topic-budget.ts +17 -2
- package/src/cohort-topic/coldstart.ts +48 -15
- package/src/cohort-topic/gossip/bus.ts +39 -2
- package/src/cohort-topic/gossip/view.ts +26 -1
- package/src/cohort-topic/member-engine.ts +36 -15
- package/src/cohort-topic/membership/publisher.ts +25 -33
- package/src/cohort-topic/membership/verifier.ts +119 -3
- package/src/cohort-topic/promotion.ts +23 -2
- package/src/cohort-topic/registration/renewal.ts +73 -1
- package/src/cohort-topic/registration/types.ts +15 -0
- package/src/cohort-topic/service.ts +41 -15
- package/src/cohort-topic/sig/payloads.ts +29 -9
- package/src/cohort-topic/traffic.ts +23 -1
- package/src/cohort-topic/walk.ts +95 -24
- package/src/cohort-topic/wire/codec.ts +12 -0
- package/src/cohort-topic/wire/index.ts +2 -0
- package/src/cohort-topic/wire/payloads.ts +14 -5
- package/src/cohort-topic/wire/primitives.ts +188 -0
- package/src/cohort-topic/wire/types.ts +136 -4
- package/src/cohort-topic/wire/validate.ts +168 -134
- package/src/collection/collection.ts +123 -40
- package/src/collection/struct.ts +44 -2
- package/src/collections/tree/tree.ts +42 -5
- package/src/index.ts +1 -0
- package/src/log/log.ts +37 -9
- package/src/log/struct.ts +5 -4
- package/src/matchmaking/wire.ts +25 -102
- package/src/network/i-key-network.ts +10 -0
- package/src/network/i-repo.ts +8 -0
- package/src/network/struct.ts +10 -0
- package/src/reactivity/push-state.ts +4 -37
- package/src/reactivity/wire-validate.ts +7 -81
- package/src/reactivity/wire.ts +3 -3
- package/src/testing/async-wait.ts +65 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/test-transactor.ts +468 -0
- package/src/transaction/actions-engine.ts +8 -6
- package/src/transaction/coordinator.ts +498 -229
- package/src/transaction/errors.ts +91 -0
- package/src/transaction/index.ts +23 -3
- package/src/transaction/operations-hash.ts +196 -0
- package/src/transaction/read-dependency-collector.ts +78 -0
- package/src/transaction/session.ts +37 -9
- package/src/transaction/transaction.ts +211 -4
- package/src/transaction/validator.ts +73 -48
- package/src/transactor/network-transactor.ts +164 -49
- package/src/transactor/transactor-source.ts +47 -12
- package/src/transform/atomic-proxy.ts +92 -49
- package/src/transform/cache-source.ts +87 -9
- package/src/transform/helpers.ts +8 -2
- package/src/transform/tracker.ts +69 -11
- package/src/utility/backoff.ts +95 -0
- package/src/utility/pending.ts +3 -1
- package/src/transaction/context.ts +0 -103
package/src/chain/chain.ts
CHANGED
|
@@ -121,7 +121,9 @@ export class Chain<TEntry> {
|
|
|
121
121
|
priorId: tail.header.id,
|
|
122
122
|
nextId: undefined,
|
|
123
123
|
} as ChainDataNode<TEntry>;
|
|
124
|
-
|
|
124
|
+
// Predecessor is the running `tail` (immediately preceding block), NOT the original
|
|
125
|
+
// `oldTail`: across a multi-block append each new block links to the one before it.
|
|
126
|
+
await this.options?.newBlock?.(newTail, tail);
|
|
125
127
|
trx.insert(newTail);
|
|
126
128
|
apply(trx, tail, [nextId$, 0, 0, newTail.header.id]);
|
|
127
129
|
tail = newTail;
|
|
@@ -300,6 +302,14 @@ export class Chain<TEntry> {
|
|
|
300
302
|
};
|
|
301
303
|
}
|
|
302
304
|
|
|
305
|
+
// NOTE: chain walks (getTail/getHead/select/next/prev) read via `store.tryGet(id)` with the
|
|
306
|
+
// DEFAULT read purpose (`value`), so every chain node stays in the OCC conflict set. This is
|
|
307
|
+
// deliberate: the chain backs the log, whose tail is the commit-ordering race arbiter (OCC
|
|
308
|
+
// "tail binding"), and enumerations return a shape-dependent result set — both are load-bearing
|
|
309
|
+
// reads that MUST be retained. The B-tree point-lookup navigation exclusion (ticket 4.7) is NOT
|
|
310
|
+
// applied here. If chain navigation reads ever need excluding, tag ONLY intermediate hops of a
|
|
311
|
+
// walk that terminates in a consumed entry, and NEVER the tail/header — get the safety predicate
|
|
312
|
+
// right first (docs/correctness.md Theorem 5).
|
|
303
313
|
async getTail(header?: ChainHeaderNode): Promise<ChainPath<TEntry> | undefined> {
|
|
304
314
|
const headerBlock = header ?? await this.getHeader();
|
|
305
315
|
let tail = headerBlock ? await this.store.tryGet(headerBlock.tailId) as ChainDataNode<TEntry> : undefined;
|
package/src/cluster/index.ts
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { RepoMessage } from "../network/repo-protocol.js";
|
|
2
|
+
import type { ClusterPeers, ClusterRecord, Signature } from "./structs.js";
|
|
3
|
+
import { sha256 } from "multiformats/hashes/sha2";
|
|
4
|
+
import { base58btc } from "multiformats/bases/base58";
|
|
5
|
+
import { toString as uint8ArrayToString } from "uint8arrays/to-string";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Membership-binding version a new coordinator emits. A record at this version folds a
|
|
9
|
+
* {@link membershipDigest} of its peer set into `messageHash` / `promiseHash` / `commitHash`, so the
|
|
10
|
+
* responsible peer set is covered by every signature (see `docs/correctness.md` §2). Absent or `1` marks
|
|
11
|
+
* a legacy *unbound* record (pre-binding history) whose hashes must verify byte-identically to before
|
|
12
|
+
* this change.
|
|
13
|
+
*/
|
|
14
|
+
export const CURRENT_MEMBERSHIP_VERSION = 2 as const;
|
|
15
|
+
|
|
16
|
+
/** Deterministic JSON: sorts object keys so the hash is independent of key insertion order. */
|
|
17
|
+
function canonicalJson(value: unknown): string {
|
|
18
|
+
return JSON.stringify(value, (_, v) =>
|
|
19
|
+
v && typeof v === 'object' && !Array.isArray(v)
|
|
20
|
+
? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = v[k]; return o; }, {})
|
|
21
|
+
: v
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Canonical membership digest for a peer set: `base64url(SHA256(canonicalJson(sorted peer-id list)))`.
|
|
27
|
+
*
|
|
28
|
+
* Derived from the sorted peer-id list ONLY — not multiaddrs or public keys. Multiaddrs churn and a
|
|
29
|
+
* peer's public key is already a function of its id, so folding either in would make identity unstable
|
|
30
|
+
* without adding agreement value. The set of ids IS the membership. The digest is therefore independent
|
|
31
|
+
* of peer-map key insertion order and of multiaddr / pubkey contents; adding or removing one id changes
|
|
32
|
+
* it. `membershipDigest({})` (the empty set) is a fixed constant.
|
|
33
|
+
*/
|
|
34
|
+
export async function membershipDigest(peers: ClusterPeers): Promise<string> {
|
|
35
|
+
const ids = Object.keys(peers ?? {}).sort();
|
|
36
|
+
const bytes = new TextEncoder().encode(canonicalJson(ids));
|
|
37
|
+
const hash = await sha256.digest(bytes);
|
|
38
|
+
return uint8ArrayToString(hash.digest, 'base64url');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The membership digest that participates in a record's hashes, or `undefined` for a legacy (v1 /
|
|
43
|
+
* unversioned) record. `undefined` selects the pre-binding hashing (empty-string concat is a no-op), so
|
|
44
|
+
* a v1 record hashes byte-identically to before this change. A v2 record folds in its declared
|
|
45
|
+
* `membershipDigest`.
|
|
46
|
+
*/
|
|
47
|
+
export function recordMembershipDigest(record: Pick<ClusterRecord, 'membershipVersion' | 'membershipDigest'>): string | undefined {
|
|
48
|
+
return record.membershipVersion === 2 ? record.membershipDigest : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `messageHash` = `base58btc(SHA256(canonicalJson(message) + digest))`.
|
|
53
|
+
*
|
|
54
|
+
* `digest` `undefined` → legacy v1 preimage (`canonicalJson(message)` only); passing the membership
|
|
55
|
+
* digest folds the peer set in (v2). Empty-string concat means the v1 image is byte-identical to the
|
|
56
|
+
* pre-binding implementation.
|
|
57
|
+
*/
|
|
58
|
+
export async function computeClusterMessageHash(message: RepoMessage, digest?: string): Promise<string> {
|
|
59
|
+
const bytes = new TextEncoder().encode(canonicalJson(message) + (digest ?? ''));
|
|
60
|
+
const hash = await sha256.digest(bytes);
|
|
61
|
+
return base58btc.encode(hash.digest);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `promiseHash` = `base64url(SHA256(messageHash + canonicalJson(message) + digest))`.
|
|
66
|
+
*
|
|
67
|
+
* `digest` `undefined` → legacy v1 preimage; passing it folds the peer set in (v2).
|
|
68
|
+
*/
|
|
69
|
+
export async function computeClusterPromiseHash(messageHash: string, message: RepoMessage, digest?: string): Promise<string> {
|
|
70
|
+
const bytes = new TextEncoder().encode(messageHash + canonicalJson(message) + (digest ?? ''));
|
|
71
|
+
const hash = await sha256.digest(bytes);
|
|
72
|
+
return uint8ArrayToString(hash.digest, 'base64url');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `commitHash` = `base64url(SHA256(messageHash + canonicalJson(message) + digest + canonicalJson(promises)))`.
|
|
77
|
+
*
|
|
78
|
+
* `digest` `undefined` → legacy v1 preimage; passing it folds the peer set in (v2). The digest sits
|
|
79
|
+
* between the message and the promises image, matching the v2 layout in `docs/correctness.md`.
|
|
80
|
+
*/
|
|
81
|
+
export async function computeClusterCommitHash(messageHash: string, message: RepoMessage, promises: Record<string, Signature>, digest?: string): Promise<string> {
|
|
82
|
+
const bytes = new TextEncoder().encode(messageHash + canonicalJson(message) + (digest ?? '') + canonicalJson(promises));
|
|
83
|
+
const hash = await sha256.digest(bytes);
|
|
84
|
+
return uint8ArrayToString(hash.digest, 'base64url');
|
|
85
|
+
}
|
package/src/cluster/structs.ts
CHANGED
|
@@ -17,6 +17,16 @@ export type ClusterPeers = {
|
|
|
17
17
|
export type ClusterRecord = {
|
|
18
18
|
messageHash: string; // Serves as a unique identifier for the clustered transaction record
|
|
19
19
|
peers: ClusterPeers;
|
|
20
|
+
/**
|
|
21
|
+
* Membership-binding version. Absent or `1` = legacy *unbound* record: the peer set is NOT covered by
|
|
22
|
+
* any hash (pre-binding history and its stored commit certs verify byte-identically to before). `2` =
|
|
23
|
+
* the sorted peer-id set (as {@link ClusterRecord.membershipDigest}) is folded into `messageHash`,
|
|
24
|
+
* `promiseHash`, and `commitHash`, so two different peer sets yield two different `messageHash`es.
|
|
25
|
+
* New coordinators always emit `2`. See `packages/db-core/src/cluster/membership.ts`.
|
|
26
|
+
*/
|
|
27
|
+
membershipVersion?: 1 | 2;
|
|
28
|
+
/** Membership digest of {@link ClusterRecord.peers}; present iff `membershipVersion === 2`. base64url. */
|
|
29
|
+
membershipDigest?: string;
|
|
20
30
|
message: RepoMessage;
|
|
21
31
|
coordinatingBlockIds?: string[];
|
|
22
32
|
promises: { [peerId: string]: Signature };
|
|
@@ -37,8 +47,18 @@ export type ClusterRecord = {
|
|
|
37
47
|
};
|
|
38
48
|
}
|
|
39
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Single source of truth for the default super-majority threshold — the fraction of a cluster's peers
|
|
52
|
+
* that must promise before a transaction may proceed. Every component that falls back to a default when
|
|
53
|
+
* config is absent (cluster member, coordinator policy, node composition root) references THIS constant,
|
|
54
|
+
* so a member cannot silently default to a different threshold than the coordinator that commits. Explicit
|
|
55
|
+
* caller-supplied thresholds are unaffected; this only unifies the *absent-config* default.
|
|
56
|
+
* 0.75 = 3/4: chosen because the coordinator (which actually commits) already used it and the type documents it.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_SUPER_MAJORITY_THRESHOLD = 0.75;
|
|
59
|
+
|
|
40
60
|
export interface ClusterConsensusConfig {
|
|
41
|
-
/** Super-majority threshold for promises (default 0.75 = 3/4) */
|
|
61
|
+
/** Super-majority threshold for promises (default {@link DEFAULT_SUPER_MAJORITY_THRESHOLD} = 0.75 = 3/4) */
|
|
42
62
|
superMajorityThreshold: number;
|
|
43
63
|
/** Simple majority threshold for commits (default 0.51 = >50%) */
|
|
44
64
|
simpleMajorityThreshold: number;
|
|
@@ -48,6 +68,20 @@ export interface ClusterConsensusConfig {
|
|
|
48
68
|
allowClusterDownsize: boolean;
|
|
49
69
|
/** Tolerance for cluster size variance as fraction (default 0.5 = 50%) */
|
|
50
70
|
clusterSizeTolerance: number;
|
|
71
|
+
/**
|
|
72
|
+
* Configured full cluster size (the responsibility-K). Present so a cluster **member** has its own
|
|
73
|
+
* reference for "full size" when running the membership admission gate — a below-full-size declared
|
|
74
|
+
* peer set under low derivation confidence is refused (see {@link ClusterMember} admission gate).
|
|
75
|
+
* The coordinator supplies this as a required `clusterSize` via `ClusterConsensusConfig & { clusterSize: number }`.
|
|
76
|
+
*/
|
|
77
|
+
clusterSize?: number;
|
|
78
|
+
/**
|
|
79
|
+
* Fraction of the member's OWN confident cluster-size estimate a declared peer set must meet to be
|
|
80
|
+
* admitted for voting (default 0.75). Below `⌈membershipAdmissionFraction · K_est⌉` a declared set is
|
|
81
|
+
* treated as an unjustified self-shrink and the member declines to approve. Distinct from
|
|
82
|
+
* {@link superMajorityThreshold} (the vote-counting threshold) — this gates *which set* may be voted on.
|
|
83
|
+
*/
|
|
84
|
+
membershipAdmissionFraction?: number;
|
|
51
85
|
/** Window for detecting partition in milliseconds (default 60000 = 1 min) */
|
|
52
86
|
partitionDetectionWindow: number;
|
|
53
87
|
/** Enable dispute escalation protocol (default false) */
|
|
@@ -77,10 +111,26 @@ export interface ClusterConsensusConfig {
|
|
|
77
111
|
commitBroadcastRetryMaxAttempts?: number;
|
|
78
112
|
/** Immediate in-line retries per failed peer inside the broadcast (default 1) */
|
|
79
113
|
commitBroadcastImmediateRetries?: number;
|
|
114
|
+
/**
|
|
115
|
+
* Immediate in-line retries per peer while collecting promises (default 1).
|
|
116
|
+
* The promise phase rides the same libp2p stream the commit broadcast does;
|
|
117
|
+
* a circuit-relay ("limited") connection can reset that stream once a
|
|
118
|
+
* per-circuit cap is hit, surfacing to the coordinator as a StreamResetError.
|
|
119
|
+
* Unlike the commit broadcast there is no follow-up scheduled retry, so a
|
|
120
|
+
* single reset here would otherwise drop the peer and sink super-majority.
|
|
121
|
+
*/
|
|
122
|
+
promiseImmediateRetries?: number;
|
|
80
123
|
/** Read-repair behavior: 'off' (only fetch on missing — legacy), 'lazy' (fetch when local age > window), 'paranoid' (always verify against cluster on read). Default 'lazy'. */
|
|
81
124
|
readRepairMode?: 'off' | 'lazy' | 'paranoid';
|
|
82
125
|
/** For 'lazy' mode: read-repair triggers when (now - localEntry.lastSeenCommitMs) > this. Default 10000. */
|
|
83
126
|
readRepairWindowMs?: number;
|
|
84
127
|
/** Per-read probability of triggering read-repair in 'lazy' mode even within the window (0..1). Default 0 (no random check). */
|
|
85
128
|
readRepairSampleRate?: number;
|
|
129
|
+
/**
|
|
130
|
+
* When FRET has no confident network-size estimate, allow an undersized cluster
|
|
131
|
+
* (peerCount < minAbsoluteClusterSize) to proceed anyway. Default false: with no
|
|
132
|
+
* confident estimate an undersized cluster is REJECTED. Turn on only for
|
|
133
|
+
* single-node / local dev where you knowingly run below the safe floor.
|
|
134
|
+
*/
|
|
135
|
+
allowUnvalidatedSmallCluster?: boolean;
|
|
86
136
|
}
|
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
*
|
|
6
6
|
* ```
|
|
7
7
|
* coord_0(_, topicId) = H(0x00 ‖ topicId)
|
|
8
|
-
* coord_d(P, topicId) = H(d ‖ prefix(P, d·log₂F) ‖ topicId) for d ≥ 1
|
|
8
|
+
* coord_d(P, topicId) = H(d ‖ prefix(H(P), d·log₂F) ‖ topicId) for d ≥ 1
|
|
9
9
|
* ```
|
|
10
10
|
*
|
|
11
|
+
* `P` is ring-hashed before the prefix so the shard input is uniformly distributed across
|
|
12
|
+
* participants (the raw peer-id string bytes share a near-constant `12D3Koo…` prefix that would
|
|
13
|
+
* collapse all tier-`d` shards to one coordinate). The wire field `participantCoord` keeps the
|
|
14
|
+
* unmodified peer id so the Ed25519 key remains recoverable; the ring-hash is applied only inside
|
|
15
|
+
* the addressing math.
|
|
16
|
+
*
|
|
11
17
|
* `H` is the injected {@link IRingHash} (db-core's own SHA-256 truncated to the ring width — **not**
|
|
12
18
|
* a FRET import). `prefix(P, n)` is the `n` most-significant bits of peer id `P`, left-padded if
|
|
13
19
|
* shorter. `F` is the fan-out (default 16, `log₂F = 4`); tier `d` has exactly `F^d` coordinates.
|
|
@@ -24,7 +30,7 @@ export interface TierAddressing {
|
|
|
24
30
|
readonly F: number;
|
|
25
31
|
/** Tier-0 root coordinate: `H(0x00 ‖ topicId)`. Peer-independent. */
|
|
26
32
|
coord0(topicId: Uint8Array): RingCoord;
|
|
27
|
-
/** Tier-`d` coordinate for `d ≥ 1`: `H(d ‖ prefix(
|
|
33
|
+
/** Tier-`d` coordinate for `d ≥ 1`: `H(d ‖ prefix(H(P), d·log₂F) ‖ topicId)` where `H(P)` is the ring-hash of `peerId`. */
|
|
28
34
|
coordD(d: number, peerId: Uint8Array, topicId: Uint8Array): RingCoord;
|
|
29
35
|
/** Dispatches `d === 0` to {@link coord0}, otherwise to {@link coordD}. */
|
|
30
36
|
coord(d: number, peerId: Uint8Array, topicId: Uint8Array): RingCoord;
|
|
@@ -92,8 +98,10 @@ export class HashTierAddressing implements TierAddressing {
|
|
|
92
98
|
if (d > 255) {
|
|
93
99
|
throw new RangeError(`tier d must fit in one byte (≤ 255), got ${d}`);
|
|
94
100
|
}
|
|
95
|
-
// H(d ‖ prefix(
|
|
96
|
-
|
|
101
|
+
// H(d ‖ prefix(H(P), d·log₂F) ‖ topicId) — ring-hash P first so the shard input is uniform
|
|
102
|
+
// NOTE: re-hashes peerId on every coordD call; a walk over a tier ladder recomputes H(self) per
|
|
103
|
+
// tier. Negligible today (walk steps are network-bound); if coord becomes hot, cache H(peerId).
|
|
104
|
+
const prefix = prefixBits(this.hash.H(peerId), d * this.log2F);
|
|
97
105
|
const input = new Uint8Array(1 + prefix.length + topicId.length);
|
|
98
106
|
input[0] = d;
|
|
99
107
|
input.set(prefix, 1);
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cohort-topic substrate — bootstrap evidence policy (anti-DoS).
|
|
3
3
|
*
|
|
4
|
-
* Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 4. A cold
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 4. A cold cohort that accepts a cold-start
|
|
5
|
+
* registration must demand evidence, so a cold topic cannot be instantiated for free by an attacker.
|
|
6
|
+
* **A follow-on cold-start (`followOn: true`) is gated identically to a root bootstrap
|
|
7
|
+
* (`bootstrap: true`)**: the cold tier-`(d+1)` child a `Promoted` redirect points at is participant-
|
|
8
|
+
* asserted (the child cannot infer the redirect — the routing key decorrelates tiers and it holds no
|
|
9
|
+
* breadcrumb of where the participant came from), so its safety cannot rest on provenance; it rests on
|
|
10
|
+
* this gate paying the same anti-abuse cost the root already pays. The accepted evidence is
|
|
11
|
+
* **tier-dependent**:
|
|
7
12
|
*
|
|
8
13
|
* - **T0 / T1** — generally no proof-of-work, because these tiers correspond to committed work; a
|
|
9
14
|
* *signed reference to a committed parent topic that does exist* is sufficient.
|
|
@@ -18,7 +23,7 @@
|
|
|
18
23
|
* `bootstrapEvidence` field — a versioned `BootstrapEvidenceEnvelopeV1`
|
|
19
24
|
* (`./bootstrap-evidence-envelope.js`), parsed crypto-free here and checked by the injected verifiers;
|
|
20
25
|
* NOT the opaque `appPayload` slot (which the cohort copies verbatim into the registration's appState).
|
|
21
|
-
* A registration that is
|
|
26
|
+
* A registration that is neither a bootstrap nor a follow-on cold-start needs no evidence and is admitted.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
import type { RegisterV1 } from "../wire/types.js";
|
|
@@ -47,9 +52,10 @@ export interface BootstrapEvidenceConfig {
|
|
|
47
52
|
/** Tier-dependent bootstrap-evidence gate for cold-root registrations. */
|
|
48
53
|
export interface BootstrapEvidence {
|
|
49
54
|
/**
|
|
50
|
-
* Whether `reg` carries acceptable
|
|
51
|
-
* always acceptable (nothing to prove). For a
|
|
52
|
-
* T2/T3 accept proof-of-work OR a reputation signature OR a
|
|
55
|
+
* Whether `reg` carries acceptable cold-start evidence for `tier`. A registration that is neither a
|
|
56
|
+
* `bootstrap` nor a `followOn` is always acceptable (nothing to prove). For a cold-start (either flag):
|
|
57
|
+
* T0/T1 require a signed parent reference; T2/T3 accept proof-of-work OR a reputation signature OR a
|
|
58
|
+
* signed parent reference.
|
|
53
59
|
*/
|
|
54
60
|
verify(reg: RegisterV1, tier: number): boolean;
|
|
55
61
|
}
|
|
@@ -68,8 +74,14 @@ class TieredBootstrapEvidence implements BootstrapEvidence {
|
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
verify(reg: RegisterV1, tier: number): boolean {
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
// A follow-on cold-start is gated identically to a root bootstrap — both must carry evidence.
|
|
78
|
+
// NOTE: follow-on cold-start is PoW-gated exactly like a root bootstrap; if a spoofed, PoW-paid
|
|
79
|
+
// follow-on instantiation ever shows up as real abuse, upgrade to a parent-vouched redirect —
|
|
80
|
+
// echo the parent cohort's threshold-signed PromotionNoticeV1 on the follow-on and verify it
|
|
81
|
+
// against the parent's MembershipCertV1 (needs a RegisterReplyV1 field + admission-path membership
|
|
82
|
+
// verify). See docs/cohort-topic.md §Anti-DoS "follow-on hardening".
|
|
83
|
+
if (reg.bootstrap !== true && reg.followOn !== true) {
|
|
84
|
+
return true; // only a cold-start (bootstrap or follow-on) must carry evidence
|
|
73
85
|
}
|
|
74
86
|
if (tier <= this.maxNoPowTier) {
|
|
75
87
|
// T0/T1: a signed reference to a committed parent topic is sufficient (no PoW expected).
|
|
@@ -11,6 +11,17 @@
|
|
|
11
11
|
* Because a stale registration is rejected outright, the guard only needs to remember correlation ids
|
|
12
12
|
* for one `maxAgeMs` window: an id older than that would be rejected on timestamp alone, so its record
|
|
13
13
|
* can be pruned. Pruning runs on access, bounding memory to the live window's worth of registrations.
|
|
14
|
+
*
|
|
15
|
+
* On top of the age-based prune, the map carries a **hard LRU `maxKeys` cap** (mirroring the sibling
|
|
16
|
+
* {@link import("./rate-limiter.js").RegisterRateLimiter}) so a flood of genuinely-fresh, admitted
|
|
17
|
+
* correlationIds cannot grow `seen` without bound before the age prune fires. When a new id would
|
|
18
|
+
* exceed the cap, the **oldest-inserted** entries are evicted until within cap. That victim is the
|
|
19
|
+
* least-bad one: replay entries are inserted once and never refreshed, so `Map` insertion order tracks
|
|
20
|
+
* timestamp order — the oldest entry is the one nearest to aging out of the window and being pruned as
|
|
21
|
+
* stale anyway. Evicting it forgives at most that entry's remaining replay-protection window: a bounded,
|
|
22
|
+
* documented tradeoff (unlike the rate limiter's fully penalty-free eviction), and one that only
|
|
23
|
+
* triggers under a flood of admitted ids — which, in the register pipeline, must also have passed the
|
|
24
|
+
* signature, rate, and bootstrap gates before ever reaching this guard.
|
|
14
25
|
*/
|
|
15
26
|
|
|
16
27
|
import { bytesKey } from "../registration/bytes.js";
|
|
@@ -22,12 +33,16 @@ const log = createLogger("cohort-topic:antidos");
|
|
|
22
33
|
export const DEFAULT_REPLAY_MAX_AGE_MS = 60_000;
|
|
23
34
|
/** Default tolerated forward clock skew: a timestamp this far past `now` is rejected as implausible. */
|
|
24
35
|
export const DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS = 5_000;
|
|
36
|
+
/** Default hard cap on remembered correlationIds; the oldest-inserted are evicted beyond this. */
|
|
37
|
+
export const DEFAULT_REPLAY_GUARD_MAX_KEYS = 100_000;
|
|
25
38
|
|
|
26
39
|
export interface CorrelationReplayGuardConfig {
|
|
27
40
|
/** A timestamp older than `now − maxAgeMs` is stale. Default {@link DEFAULT_REPLAY_MAX_AGE_MS}. */
|
|
28
41
|
maxAgeMs?: number;
|
|
29
42
|
/** A timestamp newer than `now + maxFutureSkewMs` is rejected. Default {@link DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS}. */
|
|
30
43
|
maxFutureSkewMs?: number;
|
|
44
|
+
/** Hard LRU cap on remembered correlationIds; oldest-inserted (≈ oldest-timestamp) evicted beyond this. Default {@link DEFAULT_REPLAY_GUARD_MAX_KEYS}. */
|
|
45
|
+
maxKeys?: number;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
/** Freshness + anti-replay gate over registration `correlationId`s and timestamps. */
|
|
@@ -39,6 +54,8 @@ export interface CorrelationReplayGuard {
|
|
|
39
54
|
* id on first sight of a fresh registration.
|
|
40
55
|
*/
|
|
41
56
|
accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean;
|
|
57
|
+
/** Remembered correlationId count (test/diagnostic introspection). */
|
|
58
|
+
readonly size: number;
|
|
42
59
|
}
|
|
43
60
|
|
|
44
61
|
/** A remembered acceptance, kept until its timestamp ages out of the window. */
|
|
@@ -51,6 +68,7 @@ class WindowedReplayGuard implements CorrelationReplayGuard {
|
|
|
51
68
|
private readonly seen = new Map<string, SeenEntry>();
|
|
52
69
|
private readonly maxAgeMs: number;
|
|
53
70
|
private readonly maxFutureSkewMs: number;
|
|
71
|
+
private readonly maxKeys: number;
|
|
54
72
|
/** `now` of the last prune, so pruning amortizes rather than scanning on every call. */
|
|
55
73
|
private lastPruneAt = -Infinity;
|
|
56
74
|
|
|
@@ -63,6 +81,10 @@ class WindowedReplayGuard implements CorrelationReplayGuard {
|
|
|
63
81
|
if (!(this.maxFutureSkewMs >= 0)) {
|
|
64
82
|
throw new RangeError(`maxFutureSkewMs must be >= 0, got ${this.maxFutureSkewMs}`);
|
|
65
83
|
}
|
|
84
|
+
this.maxKeys = config.maxKeys ?? DEFAULT_REPLAY_GUARD_MAX_KEYS;
|
|
85
|
+
if (!Number.isInteger(this.maxKeys) || this.maxKeys <= 0) {
|
|
86
|
+
throw new RangeError(`maxKeys must be a positive integer, got ${this.maxKeys}`);
|
|
87
|
+
}
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean {
|
|
@@ -83,6 +105,17 @@ class WindowedReplayGuard implements CorrelationReplayGuard {
|
|
|
83
105
|
log("replay-guard reject: replayed correlationId (first seen from peer=%s)", prior.peer);
|
|
84
106
|
return false; // replay
|
|
85
107
|
}
|
|
108
|
+
// New id: enforce the hard cap by evicting the oldest-inserted entries (oldest by `Map` insertion
|
|
109
|
+
// order) until this insertion stays within `maxKeys`. Since entries are inserted once and never
|
|
110
|
+
// refreshed, insertion order closely tracks timestamp order, so the victim is ≈ the oldest-timestamp
|
|
111
|
+
// — nearest to aging out as stale. (It is only ≈: ids from distinct peers can arrive out of
|
|
112
|
+
// timestamp order within the skew window, so a victim may retain slightly more window than a
|
|
113
|
+
// strictly-oldest pick. The forgiveness bound — at most one entry's remaining window — holds either way.)
|
|
114
|
+
while (this.seen.size >= this.maxKeys) {
|
|
115
|
+
const oldest = this.seen.keys().next().value;
|
|
116
|
+
if (oldest === undefined) break;
|
|
117
|
+
this.seen.delete(oldest);
|
|
118
|
+
}
|
|
86
119
|
this.seen.set(key, { timestamp, peer: bytesKey(peerId) });
|
|
87
120
|
return true;
|
|
88
121
|
}
|
|
@@ -101,6 +134,10 @@ class WindowedReplayGuard implements CorrelationReplayGuard {
|
|
|
101
134
|
}
|
|
102
135
|
}
|
|
103
136
|
}
|
|
137
|
+
|
|
138
|
+
get size(): number {
|
|
139
|
+
return this.seen.size;
|
|
140
|
+
}
|
|
104
141
|
}
|
|
105
142
|
|
|
106
143
|
/** Build a {@link CorrelationReplayGuard} over the configured staleness window and forward skew. */
|
|
@@ -26,6 +26,13 @@ export const DEFAULT_TOPICS_MAX = 2048;
|
|
|
26
26
|
export interface TopicBudgetConfig {
|
|
27
27
|
/** Max topics with forwarder state. Default {@link DEFAULT_TOPICS_MAX}. */
|
|
28
28
|
topicsMax?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Called with the evicted topic's id just before its slot is freed in {@link TopicBudget.admit}, so
|
|
31
|
+
* the caller can tear down the now-unbacked forwarder + traffic window that outlives the slot. Fires
|
|
32
|
+
* only for a genuine eviction (a zero-participant victim), never for a plain admit, an already-resident
|
|
33
|
+
* re-admit, or a refusal. Absent → no teardown hook.
|
|
34
|
+
*/
|
|
35
|
+
onEvict?: (topicId: Uint8Array) => void;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
/** Per-cohort cap on the number of topics with forwarder state, with LRU eviction of cold topics. */
|
|
@@ -60,11 +67,14 @@ interface ResidentState {
|
|
|
60
67
|
participantCount: number;
|
|
61
68
|
/** Monotonic access sequence (LRU tiebreaker; lower = least-recently used). */
|
|
62
69
|
seq: number;
|
|
70
|
+
/** The original topic-id bytes, captured on admit so `onEvict` can be handed real bytes (residents key by string). */
|
|
71
|
+
topicId: Uint8Array;
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
class LruTopicBudget implements TopicBudget {
|
|
66
75
|
private readonly residents = new Map<string, ResidentState>();
|
|
67
76
|
private readonly topicsMax: number;
|
|
77
|
+
private readonly onEvict?: (topicId: Uint8Array) => void;
|
|
68
78
|
private seqCounter = 0;
|
|
69
79
|
|
|
70
80
|
constructor(config: TopicBudgetConfig = {}) {
|
|
@@ -72,6 +82,7 @@ class LruTopicBudget implements TopicBudget {
|
|
|
72
82
|
if (!Number.isInteger(this.topicsMax) || this.topicsMax <= 0) {
|
|
73
83
|
throw new RangeError(`topicsMax must be a positive integer, got ${this.topicsMax}`);
|
|
74
84
|
}
|
|
85
|
+
this.onEvict = config.onEvict;
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
get size(): number {
|
|
@@ -94,7 +105,7 @@ class LruTopicBudget implements TopicBudget {
|
|
|
94
105
|
return true;
|
|
95
106
|
}
|
|
96
107
|
if (this.residents.size < this.topicsMax) {
|
|
97
|
-
this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter });
|
|
108
|
+
this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
|
|
98
109
|
return true;
|
|
99
110
|
}
|
|
100
111
|
// Full: only a zero-participant resident may be evicted to make room for a new topic.
|
|
@@ -103,8 +114,12 @@ class LruTopicBudget implements TopicBudget {
|
|
|
103
114
|
log("topic-budget full size=%d max=%d — refuse new topic", this.residents.size, this.topicsMax);
|
|
104
115
|
return false;
|
|
105
116
|
}
|
|
117
|
+
// Fire the teardown hook with the victim's real bytes BEFORE freeing the slot, so the callback
|
|
118
|
+
// (drop the now-unbacked forwarder + traffic window) sees a coherent state. Only a genuine
|
|
119
|
+
// eviction reaches here — `coldestEvictable()` already refused a full-of-populated budget above.
|
|
120
|
+
this.onEvict?.(this.residents.get(victim)!.topicId);
|
|
106
121
|
this.residents.delete(victim);
|
|
107
|
-
this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter });
|
|
122
|
+
this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
|
|
108
123
|
log("topic-budget evicted cold topic to admit new (size=%d max=%d)", this.residents.size, this.topicsMax);
|
|
109
124
|
return true;
|
|
110
125
|
}
|
|
@@ -11,18 +11,22 @@
|
|
|
11
11
|
* cold-start case) **or** it arrived as a follow-on to a parent cohort's `Promoted` redirect, AND
|
|
12
12
|
* - a quorum of cohort members is willing to serve `T` at the registration's tier.
|
|
13
13
|
*
|
|
14
|
-
* The `followOn` signal is
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
14
|
+
* The `followOn` signal **is** carried on the wire (`RegisterV1.followOn`), set only on the dedicated
|
|
15
|
+
* re-issue a participant sends after a parent cohort's `Promoted` redirect target answered `NoState`
|
|
16
|
+
* (the deeper child is cold). A cold high-tier cohort still returns `NoState` to a *speculative* `d_max`
|
|
17
|
+
* probe (neither flag set), but the single-direction walk means the only registrations that reach a cold
|
|
18
|
+
* tier-`(d+1)` cohort carrying `followOn: true` are those that followed the parent's `Promoted` redirect.
|
|
19
|
+
* The db-p2p cohort host derives `ctx.followOn` from the wire flag (`followOn: reg.followOn === true`) and
|
|
20
|
+
* db-core takes it as an explicit input to {@link shouldInstantiate}, keeping this module FRET-free. Because
|
|
21
|
+
* the flag is participant-forgeable, a `followOn: true` register is gated by the **same** tier-dependent
|
|
22
|
+
* bootstrap-evidence policy a `bootstrap: true` cold-root register passes (§Anti-DoS), so an unbacked
|
|
23
|
+
* follow-on never reaches this instantiation decision.
|
|
21
24
|
*
|
|
22
25
|
* Once instantiated, the new forwarder **registers itself with its tier-`(d − 1)` parent at first
|
|
23
|
-
* opportunity
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
+
* opportunity** by sending a child-link the parent authenticates + records; until that link is acked
|
|
27
|
+
* (`linked`) it {@link Forwarder.acceptsParticipants | accepts participants} but
|
|
28
|
+
* {@link Forwarder.servesParentOps | holds} notifications/queries that need parent involvement. The root
|
|
29
|
+
* (tree tier 0) has no parent and is serving immediately.
|
|
26
30
|
*
|
|
27
31
|
* **Just-promoted burst (GROUNDING-resolved: bounce, don't buffer).** A cohort that has just promoted
|
|
28
32
|
* but whose tier-`(d+1)` isn't fully instantiated yet, on a burst of new same-tier registrations,
|
|
@@ -34,6 +38,9 @@
|
|
|
34
38
|
import { attachTopicTraffic } from "./traffic.js";
|
|
35
39
|
import { bytesKey } from "./registration/bytes.js";
|
|
36
40
|
import type { RegisterReplyV1, TopicTrafficV1 } from "./wire/types.js";
|
|
41
|
+
import { createLogger } from "../logger.js";
|
|
42
|
+
|
|
43
|
+
const log = createLogger('cohort-topic:coldstart');
|
|
37
44
|
|
|
38
45
|
/** Inputs to the cold-start admission gate. */
|
|
39
46
|
export interface ColdStartTrigger {
|
|
@@ -130,13 +137,18 @@ export function promotedRedirectReply(targetTier: number, traffic?: TopicTraffic
|
|
|
130
137
|
return traffic !== undefined ? attachTopicTraffic(reply, traffic) : reply;
|
|
131
138
|
}
|
|
132
139
|
|
|
133
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Registers a newly-instantiated forwarder with its tier-`(d − 1)` parent; resolves only when the parent
|
|
142
|
+
* authenticates + records the child and returns a `linked` ack. A `rejected` reply (bad coord binding /
|
|
143
|
+
* failed threshold verify) or an unreachable parent rejects the promise, so the forwarder stays
|
|
144
|
+
* `awaiting_parent` for a later retry.
|
|
145
|
+
*/
|
|
134
146
|
export interface ParentRegistrar {
|
|
135
147
|
/**
|
|
136
148
|
* Register the forwarder for `topicId` (served at tree tier `tier`, i.e. `d`) with the cohort at
|
|
137
|
-
* `parentCoord`; resolves on ack. `opTier` is the topic's *capacity* tier (T0–T3), threaded so
|
|
138
|
-
* transport can stamp a well-formed
|
|
139
|
-
*
|
|
149
|
+
* `parentCoord`; resolves on a `linked` ack. `opTier` is the topic's *capacity* tier (T0–T3), threaded so
|
|
150
|
+
* the transport can stamp a well-formed child-link frame; absent when the caller has no op-tier context
|
|
151
|
+
* (the cohort host always supplies it from the instantiating `RegisterV1`).
|
|
140
152
|
*/
|
|
141
153
|
registerWithParent(topicId: Uint8Array, parentCoord: Uint8Array, tier: number, opTier?: number): Promise<void>;
|
|
142
154
|
}
|
|
@@ -161,6 +173,17 @@ export interface ColdStartManager {
|
|
|
161
173
|
instantiate(topicId: Uint8Array, tier: number, parentCoord?: Uint8Array, opTier?: number): Forwarder;
|
|
162
174
|
/** The tracked forwarder for `topicId`, or `undefined`. */
|
|
163
175
|
get(topicId: Uint8Array): Forwarder | undefined;
|
|
176
|
+
/**
|
|
177
|
+
* Drop the forwarder for `topicId` (budget eviction / teardown). Idempotent; a no-op if the topic is
|
|
178
|
+
* not tracked. After this, {@link get} returns `undefined` for `topicId`, so the cohort no longer
|
|
179
|
+
* reports serving it via cold-start state.
|
|
180
|
+
*/
|
|
181
|
+
remove(topicId: Uint8Array): void;
|
|
182
|
+
/**
|
|
183
|
+
* Whether this manager currently tracks any forwarder. The host's idle-engine eviction reads this to
|
|
184
|
+
* avoid reclaiming a cohort that holds a live (possibly `awaiting_parent`) cold-start forwarder.
|
|
185
|
+
*/
|
|
186
|
+
hasForwarders(): boolean;
|
|
164
187
|
}
|
|
165
188
|
|
|
166
189
|
class TrackingColdStartManager implements ColdStartManager {
|
|
@@ -187,7 +210,7 @@ class TrackingColdStartManager implements ColdStartManager {
|
|
|
187
210
|
.registerWithParent(topicId, parentCoord, tier, opTier)
|
|
188
211
|
.then(() => forwarder.onParentAck())
|
|
189
212
|
.catch((err: unknown) => {
|
|
190
|
-
|
|
213
|
+
log('WARN: parent registration for tier-%s forwarder failed %o', tier, err);
|
|
191
214
|
});
|
|
192
215
|
}
|
|
193
216
|
return forwarder;
|
|
@@ -196,6 +219,16 @@ class TrackingColdStartManager implements ColdStartManager {
|
|
|
196
219
|
get(topicId: Uint8Array): Forwarder | undefined {
|
|
197
220
|
return this.forwarders.get(bytesKey(topicId));
|
|
198
221
|
}
|
|
222
|
+
|
|
223
|
+
remove(topicId: Uint8Array): void {
|
|
224
|
+
// Idempotent: `Map.delete` on an absent key is a safe no-op, so a double-remove (or a remove of a
|
|
225
|
+
// never-instantiated topic) does nothing.
|
|
226
|
+
this.forwarders.delete(bytesKey(topicId));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
hasForwarders(): boolean {
|
|
230
|
+
return this.forwarders.size > 0;
|
|
231
|
+
}
|
|
199
232
|
}
|
|
200
233
|
|
|
201
234
|
/** Build a {@link ColdStartManager} over the injected parent registrar. */
|
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
|
|
26
26
|
import { bytesEqual, bytesKey } from "../registration/bytes.js";
|
|
27
27
|
import type { RegistrationStore } from "../registration/types.js";
|
|
28
|
+
import { clampTtl } from "../registration/types.js";
|
|
28
29
|
import type { ICohortGossipTransport, PeerRef, RingCoord } from "../ports.js";
|
|
29
30
|
import { b64urlToBytes, decodeCohortGossipV1, encodeCohortMessage } from "../wire/codec.js";
|
|
30
|
-
import type { CohortGossipV1 } from "../wire/types.js";
|
|
31
|
+
import type { ChildLinkRefV1, CohortGossipV1 } from "../wire/types.js";
|
|
31
32
|
import { fromGossipRecord } from "./records.js";
|
|
32
33
|
import { createCohortView, type CohortView, type MutableCohortView } from "./view.js";
|
|
33
34
|
|
|
@@ -70,6 +71,17 @@ export interface CohortGossipBusDeps {
|
|
|
70
71
|
* callback so this gossip/replication module carries no anti-DoS dependency. Absent → skipped.
|
|
71
72
|
*/
|
|
72
73
|
onRecordsEvicted?: (topicIds: readonly Uint8Array[]) => void;
|
|
74
|
+
/**
|
|
75
|
+
* Optional hook fired for the child-cohort link/unlink deltas on an inbound frame (db-p2p merges them into
|
|
76
|
+
* this coord's child registry, last-writer-wins by `effectiveAt`), so every parent member converges on the
|
|
77
|
+
* same child set — not only the FRET-routed member that recorded the child. Fired for **our-coord** frames
|
|
78
|
+
* regardless of epoch match: the child set is keyed by child coord, not the parent's epoch, so a parent
|
|
79
|
+
* membership rotation (an epoch drift) must not skip the merge (unlike record deltas, whose slot
|
|
80
|
+
* assignments are epoch-scoped). The merge is a direct registry write — it does **not** re-enqueue the
|
|
81
|
+
* delta, so a received link/unlink is not re-gossiped (one broadcast reaches the whole cohort). Kept as a
|
|
82
|
+
* decoupled callback so this module carries no child-registry dependency. Absent → skipped.
|
|
83
|
+
*/
|
|
84
|
+
onChildDeltas?: (childLinks: readonly ChildLinkRefV1[], childUnlinks: readonly ChildLinkRefV1[]) => void;
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
/** Intra-cohort gossip bus (merge logic over an injected transport). */
|
|
@@ -134,6 +146,14 @@ class TransportCohortGossipBus implements CohortGossipBus {
|
|
|
134
146
|
if (epochMatches) {
|
|
135
147
|
this.mergeRecords(g, now);
|
|
136
148
|
}
|
|
149
|
+
// Child link/unlink deltas merge regardless of epoch match: the child set is keyed by child coord, not
|
|
150
|
+
// the parent's epoch, so a rotation (epoch drift) must not drop it. Freshness (last-writer-wins by
|
|
151
|
+
// effectiveAt) lives in the registry the callback writes.
|
|
152
|
+
const childLinks = g.childLinks ?? [];
|
|
153
|
+
const childUnlinks = g.childUnlinks ?? [];
|
|
154
|
+
if (this.deps.onChildDeltas !== undefined && (childLinks.length > 0 || childUnlinks.length > 0)) {
|
|
155
|
+
this.deps.onChildDeltas(childLinks, childUnlinks);
|
|
156
|
+
}
|
|
137
157
|
}
|
|
138
158
|
|
|
139
159
|
view(): CohortView {
|
|
@@ -180,6 +200,11 @@ class TransportCohortGossipBus implements CohortGossipBus {
|
|
|
180
200
|
private mergeRecords(g: CohortGossipV1, now: number): void {
|
|
181
201
|
for (const gr of g.records ?? []) {
|
|
182
202
|
const incoming = fromGossipRecord(gr);
|
|
203
|
+
// Gossip is a second admission path into the store — a peer (buggy, unpatched, or hostile)
|
|
204
|
+
// can replicate a record whose TTL never passed local `accept()`'s clamp. Re-clamp here so
|
|
205
|
+
// no replica can hold an unbounded lifetime that wedges the topic budget. Clamp BEFORE the
|
|
206
|
+
// dead-check below so a poison TTL cannot dodge staleness eviction.
|
|
207
|
+
incoming.ttl = clampTtl(incoming.ttl);
|
|
183
208
|
// A record already past its TTL is dead; merging it would resurrect a registration the
|
|
184
209
|
// owner has (or soon will have) evicted. Drop it, matching `store.evictStale`'s predicate
|
|
185
210
|
// so replication can never reintroduce what local eviction removes.
|
|
@@ -200,7 +225,19 @@ class TransportCohortGossipBus implements CohortGossipBus {
|
|
|
200
225
|
let evictedTopics: Map<string, Uint8Array> | undefined;
|
|
201
226
|
for (const ref of g.evicted ?? []) {
|
|
202
227
|
const topicId = b64urlToBytes(ref.topicId);
|
|
203
|
-
|
|
228
|
+
const participantId = b64urlToBytes(ref.participantId);
|
|
229
|
+
// NOTE: freshness guard adds one getByParticipant per eviction ref (on top of the delete's own
|
|
230
|
+
// lookup); evictions are low-volume, but if this path ever shows as hot, fold the guard into a
|
|
231
|
+
// single conditional-delete store op.
|
|
232
|
+
const held = this.deps.store.getByParticipant(topicId, participantId);
|
|
233
|
+
// A stale eviction (older than the held record) must not delete a fresher re-registration.
|
|
234
|
+
// Evictions are otherwise NOT last-writer-wins like merges are, so a reordered/slow delta would
|
|
235
|
+
// win against a record the participant already renewed. Skip it — and, since nothing drained,
|
|
236
|
+
// do NOT add the topic to `evictedTopics` (no delete → no budget re-touch).
|
|
237
|
+
if (held !== undefined && held.lastPing > ref.lastPing) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
this.deps.store.delete(topicId, participantId);
|
|
204
241
|
if (this.deps.onRecordsEvicted !== undefined) {
|
|
205
242
|
(evictedTopics ??= new Map()).set(bytesKey(topicId), topicId);
|
|
206
243
|
}
|