@optimystic/db-p2p 1.0.0-beta.3 → 1.1.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/block-transfer-service.d.ts +73 -1
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +70 -3
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/block-transfer.d.ts +16 -1
- package/dist/src/cluster/block-transfer.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer.js +73 -72
- package/dist/src/cluster/block-transfer.js.map +1 -1
- package/dist/src/cluster/client.d.ts +4 -6
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +8 -13
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-policy.d.ts +2 -1
- package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
- package/dist/src/cluster/cluster-policy.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +155 -9
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +446 -58
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/commit-proof.js +2 -2
- package/dist/src/cluster/race-resolution.d.ts +51 -7
- package/dist/src/cluster/race-resolution.d.ts.map +1 -1
- package/dist/src/cluster/race-resolution.js +71 -7
- package/dist/src/cluster/race-resolution.js.map +1 -1
- package/dist/src/cluster/rebalance-monitor.d.ts +67 -5
- package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
- package/dist/src/cluster/rebalance-monitor.js +120 -26
- package/dist/src/cluster/rebalance-monitor.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +34 -47
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +8 -6
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/cohort-topic/membership-source.js +1 -1
- package/dist/src/cohort-topic/membership-source.js.map +1 -1
- package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -1
- package/dist/src/cohort-topic/peer-codec.js +5 -2
- package/dist/src/cohort-topic/peer-codec.js.map +1 -1
- package/dist/src/cohort-topic/stream-util.d.ts +24 -3
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +36 -3
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/cohort-topic/topic-router.d.ts +9 -0
- package/dist/src/cohort-topic/topic-router.d.ts.map +1 -1
- package/dist/src/cohort-topic/topic-router.js +12 -2
- package/dist/src/cohort-topic/topic-router.js.map +1 -1
- package/dist/src/dispute/client.d.ts.map +1 -1
- package/dist/src/dispute/client.js +15 -3
- package/dist/src/dispute/client.js.map +1 -1
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +78 -17
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +234 -147
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +35 -1
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +151 -26
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/libp2p-node-rn.d.ts +2 -2
- package/dist/src/libp2p-node-rn.d.ts.map +1 -1
- package/dist/src/libp2p-node-rn.js.map +1 -1
- package/dist/src/libp2p-node.d.ts +2 -2
- package/dist/src/libp2p-node.d.ts.map +1 -1
- package/dist/src/libp2p-node.js.map +1 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +6 -1
- package/dist/src/logger.js.map +1 -1
- package/dist/src/matchmaking/query-transport.d.ts +3 -4
- package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
- package/dist/src/matchmaking/query-transport.js +9 -9
- package/dist/src/matchmaking/query-transport.js.map +1 -1
- package/dist/src/network/network-manager-service.d.ts +10 -5
- package/dist/src/network/network-manager-service.d.ts.map +1 -1
- package/dist/src/network/network-manager-service.js +11 -2
- package/dist/src/network/network-manager-service.js.map +1 -1
- package/dist/src/network/relay-reservation.d.ts +170 -0
- package/dist/src/network/relay-reservation.d.ts.map +1 -0
- package/dist/src/network/relay-reservation.js +527 -0
- package/dist/src/network/relay-reservation.js.map +1 -0
- package/dist/src/pend-validation.d.ts +3 -2
- package/dist/src/pend-validation.d.ts.map +1 -1
- package/dist/src/pend-validation.js +3 -2
- package/dist/src/pend-validation.js.map +1 -1
- package/dist/src/protocol-client.d.ts +8 -1
- package/dist/src/protocol-client.d.ts.map +1 -1
- package/dist/src/protocol-client.js +11 -0
- package/dist/src/protocol-client.js.map +1 -1
- package/dist/src/reactivity/forwarder-host.d.ts +2 -1
- package/dist/src/reactivity/forwarder-host.d.ts.map +1 -1
- package/dist/src/reactivity/forwarder-host.js +2 -1
- package/dist/src/reactivity/forwarder-host.js.map +1 -1
- package/dist/src/reactivity/origination-manager.d.ts +3 -3
- package/dist/src/reactivity/origination-manager.js +3 -3
- package/dist/src/reactivity/recover-transport.d.ts +22 -14
- package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
- package/dist/src/reactivity/recover-transport.js +32 -19
- package/dist/src/reactivity/recover-transport.js.map +1 -1
- package/dist/src/reactivity/subscription-manager.d.ts +2 -2
- package/dist/src/reactivity/topic-bytes.d.ts +6 -7
- package/dist/src/reactivity/topic-bytes.d.ts.map +1 -1
- package/dist/src/reactivity/topic-bytes.js +8 -9
- package/dist/src/reactivity/topic-bytes.js.map +1 -1
- package/dist/src/repo/client.d.ts +5 -2
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +49 -40
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +96 -11
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +164 -25
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +240 -122
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +627 -473
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/i-under-replication-ledger.d.ts +87 -0
- package/dist/src/repo/i-under-replication-ledger.d.ts.map +1 -0
- package/dist/src/repo/i-under-replication-ledger.js +2 -0
- package/dist/src/repo/i-under-replication-ledger.js.map +1 -0
- package/dist/src/repo/kv-under-replication-ledger.d.ts +91 -0
- package/dist/src/repo/kv-under-replication-ledger.d.ts.map +1 -0
- package/dist/src/repo/kv-under-replication-ledger.js +287 -0
- package/dist/src/repo/kv-under-replication-ledger.js.map +1 -0
- package/dist/src/repo/responsibility.d.ts +37 -0
- package/dist/src/repo/responsibility.d.ts.map +1 -0
- package/dist/src/repo/responsibility.js +37 -0
- package/dist/src/repo/responsibility.js.map +1 -0
- package/dist/src/repo/service.d.ts +44 -15
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +64 -27
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/repo/stuck-reservation.d.ts +127 -0
- package/dist/src/repo/stuck-reservation.d.ts.map +1 -0
- package/dist/src/repo/stuck-reservation.js +179 -0
- package/dist/src/repo/stuck-reservation.js.map +1 -0
- package/dist/src/repo/under-replication-drain.d.ts +208 -0
- package/dist/src/repo/under-replication-drain.d.ts.map +1 -0
- package/dist/src/repo/under-replication-drain.js +461 -0
- package/dist/src/repo/under-replication-drain.js.map +1 -0
- package/dist/src/rn.d.ts +5 -0
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +5 -0
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/block-latch.d.ts +3 -2
- package/dist/src/storage/block-latch.d.ts.map +1 -1
- package/dist/src/storage/block-latch.js +7 -4
- package/dist/src/storage/block-latch.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts +45 -3
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +156 -9
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +57 -5
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-block-storage.js.map +1 -1
- package/dist/src/storage/pending-claim.d.ts +167 -0
- package/dist/src/storage/pending-claim.d.ts.map +1 -0
- package/dist/src/storage/pending-claim.js +156 -0
- package/dist/src/storage/pending-claim.js.map +1 -0
- package/dist/src/storage/raw-store-codec.d.ts +13 -0
- package/dist/src/storage/raw-store-codec.d.ts.map +1 -1
- package/dist/src/storage/raw-store-codec.js +24 -3
- package/dist/src/storage/raw-store-codec.js.map +1 -1
- package/dist/src/storage/restoration-coordinator.d.ts.map +1 -1
- package/dist/src/storage/restoration-coordinator.js +2 -1
- package/dist/src/storage/restoration-coordinator.js.map +1 -1
- package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -1
- package/dist/src/storage/ring-shift-coordinator.js +3 -3
- package/dist/src/storage/ring-shift-coordinator.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +122 -5
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +245 -68
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/storage/struct.d.ts +58 -0
- package/dist/src/storage/struct.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts +77 -11
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +226 -100
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/package.json +2 -2
- package/readme.md +27 -3
- package/src/cluster/block-transfer-service.ts +132 -4
- package/src/cluster/block-transfer.ts +79 -84
- package/src/cluster/client.ts +115 -119
- package/src/cluster/cluster-policy.ts +2 -1
- package/src/cluster/cluster-repo.ts +493 -65
- package/src/cluster/commit-proof.ts +2 -2
- package/src/cluster/race-resolution.ts +76 -7
- package/src/cluster/rebalance-monitor.ts +677 -539
- package/src/cluster/spread-on-churn.ts +34 -54
- package/src/cohort-topic/host.ts +8 -6
- package/src/cohort-topic/membership-source.ts +1 -1
- package/src/cohort-topic/peer-codec.ts +34 -31
- package/src/cohort-topic/stream-util.ts +39 -4
- package/src/cohort-topic/topic-router.ts +12 -2
- package/src/dispute/client.ts +17 -7
- package/src/index.ts +64 -59
- package/src/libp2p-key-network.ts +1377 -1260
- package/src/libp2p-node-base.ts +186 -27
- package/src/libp2p-node-rn.ts +31 -30
- package/src/libp2p-node.ts +37 -36
- package/src/logger.ts +6 -1
- package/src/matchmaking/query-transport.ts +9 -9
- package/src/network/network-manager-service.ts +16 -5
- package/src/network/relay-reservation.ts +636 -0
- package/src/pend-validation.ts +3 -2
- package/src/protocol-client.ts +13 -1
- package/src/reactivity/forwarder-host.ts +2 -1
- package/src/reactivity/origination-manager.ts +3 -3
- package/src/reactivity/recover-transport.ts +39 -22
- package/src/reactivity/subscription-manager.ts +2 -2
- package/src/reactivity/topic-bytes.ts +35 -37
- package/src/repo/client.ts +172 -173
- package/src/repo/cluster-coordinator.ts +183 -25
- package/src/repo/coordinator-repo.ts +656 -506
- package/src/repo/i-under-replication-ledger.ts +89 -0
- package/src/repo/kv-under-replication-ledger.ts +302 -0
- package/src/repo/responsibility.ts +48 -0
- package/src/repo/service.ts +76 -39
- package/src/repo/stuck-reservation.ts +224 -0
- package/src/repo/under-replication-drain.ts +582 -0
- package/src/rn.ts +74 -69
- package/src/storage/block-latch.ts +7 -4
- package/src/storage/block-storage.ts +161 -10
- package/src/storage/i-block-storage.ts +60 -5
- package/src/storage/pending-claim.ts +200 -0
- package/src/storage/raw-store-codec.ts +26 -3
- package/src/storage/restoration-coordinator.ts +212 -211
- package/src/storage/ring-shift-coordinator.ts +271 -271
- package/src/storage/storage-repo.ts +280 -72
- package/src/storage/struct.ts +58 -0
- package/src/testing/mesh-harness.ts +293 -112
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ActionId, BlockId, DurabilityQuorum } from "@optimystic/db-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The classes a commit can be acknowledged at while still owing somebody a copy. `full` is excluded
|
|
5
|
+
* by type: a block every cohort member confirmed owes nobody a push, so an entry recording one is
|
|
6
|
+
* a contradiction rather than a state to handle.
|
|
7
|
+
*/
|
|
8
|
+
export type ShortfallQuorum = Exclude<DurabilityQuorum, 'full'>;
|
|
9
|
+
|
|
10
|
+
/** One block this node acknowledged below full replication, and who is still missing it. */
|
|
11
|
+
export type UnderReplicatedEntry = {
|
|
12
|
+
readonly blockId: BlockId;
|
|
13
|
+
/** The highest under-replicated revision of this block. A later revision SUPERSEDES an earlier
|
|
14
|
+
* one: pushing the newer materialization satisfies the older, so entries are keyed by block
|
|
15
|
+
* alone and this field is overwritten, never appended to. */
|
|
16
|
+
readonly rev: number;
|
|
17
|
+
readonly actionId: ActionId;
|
|
18
|
+
/** The class the commit was acknowledged at — `local`, `unrouted`, or `majority`. */
|
|
19
|
+
readonly quorum: ShortfallQuorum;
|
|
20
|
+
/** Cohort members that had not confirmed holding `rev`, by peer-id string.
|
|
21
|
+
* EMPTY when the cohort could not be named (`local`, `unrouted`): there was nobody to name,
|
|
22
|
+
* and the drain must re-resolve the cohort at drain time instead. An empty array therefore
|
|
23
|
+
* means "unknown", not "nobody". */
|
|
24
|
+
readonly missingPeerIds: readonly string[];
|
|
25
|
+
/** When this shortfall was first recorded, in milliseconds since the epoch. */
|
|
26
|
+
readonly recordedAt: number;
|
|
27
|
+
/** Consecutive unsuccessful drain rounds — the give-up counter the drain owns. */
|
|
28
|
+
readonly attempts: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The durable record of blocks this node acknowledged below full replication. Written by
|
|
33
|
+
* `CoordinatorRepo.commit` at the moment it answers the writer — the only moment the missing
|
|
34
|
+
* members are known — and read by whatever pushes the missing copies later.
|
|
35
|
+
*
|
|
36
|
+
* Every operation on one block is applied in call order, so each read-compare-write below is atomic
|
|
37
|
+
* with respect to the others for the same block. That guarantee is per ledger INSTANCE: one node owns
|
|
38
|
+
* one ledger over its key-value store, and a second instance writing the same store concurrently is
|
|
39
|
+
* outside it.
|
|
40
|
+
*/
|
|
41
|
+
export interface IUnderReplicationLedger {
|
|
42
|
+
/**
|
|
43
|
+
* Record a block's shortfall. Against an existing entry for the same block:
|
|
44
|
+
* - a HIGHER `rev` replaces it outright, `attempts` and `recordedAt` included — a fresh shortfall
|
|
45
|
+
* deserves a fresh give-up budget;
|
|
46
|
+
* - the SAME `rev` is the same shortfall observed again: the new `quorum` and `missingPeerIds`
|
|
47
|
+
* replace the old, and the existing `recordedAt` and `attempts` are kept;
|
|
48
|
+
* - a LOWER `rev` is skipped, so a slow older commit cannot clobber a newer one.
|
|
49
|
+
*/
|
|
50
|
+
record(entry: UnderReplicatedEntry): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* A commit at `rev` reached full replication: delete the block's entry, unless that entry records
|
|
53
|
+
* a HIGHER revision — a newer shortfall is not settled by an older commit finishing.
|
|
54
|
+
*/
|
|
55
|
+
settle(blockId: BlockId, rev: number): Promise<void>;
|
|
56
|
+
get(blockId: BlockId): Promise<UnderReplicatedEntry | undefined>;
|
|
57
|
+
/** Every outstanding entry, oldest `recordedAt` first. The drain pages nothing today: the set is
|
|
58
|
+
* bounded by the node's own owned-block count and shrinks as copies land. */
|
|
59
|
+
list(): Promise<UnderReplicatedEntry[]>;
|
|
60
|
+
/** How many entries are outstanding — the drain's "is anything still owed" check between
|
|
61
|
+
* passes, so it must be cheap once warm: answered from memory after the first call. */
|
|
62
|
+
size(): Promise<number>;
|
|
63
|
+
/**
|
|
64
|
+
* Remove `peerIds` from an entry's missing set, deleting the entry when it empties. Returns the
|
|
65
|
+
* remaining entry, or `undefined` when it was deleted or never existed.
|
|
66
|
+
*
|
|
67
|
+
* An entry whose missing set is UNKNOWN (empty — recorded `local` or `unrouted`) is returned
|
|
68
|
+
* unchanged: removing named peers from an unknown set cannot prove it empty. `name` its missing
|
|
69
|
+
* set first, or `delete` it once the whole re-resolved cohort has confirmed.
|
|
70
|
+
*
|
|
71
|
+
* `heldRev` is the revision the peers confirmed holding. When given, an entry recording a HIGHER
|
|
72
|
+
* revision is returned unchanged: a copy of an older revision says nothing about a newer
|
|
73
|
+
* shortfall recorded while that copy was in flight. A copy of a newer revision satisfies an older
|
|
74
|
+
* entry — a later revision supersedes an earlier one — so `heldRev` above the entry's is fine.
|
|
75
|
+
*/
|
|
76
|
+
satisfy(blockId: BlockId, peerIds: readonly string[], heldRev?: number): Promise<UnderReplicatedEntry | undefined>;
|
|
77
|
+
/**
|
|
78
|
+
* Give an entry recorded with an UNKNOWN missing set the members the drain has since resolved,
|
|
79
|
+
* so `satisfy` has a set to shrink. Applies only to the entry as it stands — at exactly `rev`,
|
|
80
|
+
* and still unnamed: an entry recording another revision or already naming its members is
|
|
81
|
+
* returned unchanged, and an absent one is NOT created (a `record` here would resurrect an entry a
|
|
82
|
+
* concurrent `settle` just removed). `recordedAt` and `attempts` are kept. Returns the entry as it
|
|
83
|
+
* now stands, or `undefined` when there is none.
|
|
84
|
+
*/
|
|
85
|
+
name(blockId: BlockId, rev: number, missingPeerIds: readonly string[]): Promise<UnderReplicatedEntry | undefined>;
|
|
86
|
+
/** Bump the give-up counter after an unsuccessful drain round. A no-op for an absent entry. */
|
|
87
|
+
noteAttempt(blockId: BlockId): Promise<void>;
|
|
88
|
+
delete(blockId: BlockId): Promise<void>;
|
|
89
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type { BlockId } from "@optimystic/db-core";
|
|
2
|
+
import type { IKVStore } from "../storage/i-kv-store.js";
|
|
3
|
+
import type { IUnderReplicationLedger, ShortfallQuorum, UnderReplicatedEntry } from "./i-under-replication-ledger.js";
|
|
4
|
+
import { createLogger } from "../logger.js";
|
|
5
|
+
|
|
6
|
+
const log = createLogger('under-replication-ledger');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Key namespace: `under-replicated/<blockId>` → JSON(UnderReplicatedEntry). A fixed literal first
|
|
10
|
+
* segment, which is what keeps this store safe to share a base path with `FileRawStorage` — see
|
|
11
|
+
* the NOTE on `FileKVStore.keyToPath` for the condition sharing would break under.
|
|
12
|
+
*/
|
|
13
|
+
export const UNDER_REPLICATED_KEY_PREFIX = 'under-replicated/';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The backstop cap. Generous on purpose: the drain's give-up counter is what retires entries, and
|
|
17
|
+
* this only stops a node whose partner never returns from growing the ledger without limit.
|
|
18
|
+
* NOTE: every entry is one small file under `FileKVStore` and one block id held in memory by the
|
|
19
|
+
* eviction index; if a deployment ever sits at this cap, measure both before raising it.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_UNDER_REPLICATION_MAX_ENTRIES = 100_000;
|
|
22
|
+
|
|
23
|
+
export type KvUnderReplicationLedgerOptions = {
|
|
24
|
+
/** Hard cap on outstanding entries; the oldest-recorded entry is evicted beyond it. Default
|
|
25
|
+
* {@link DEFAULT_UNDER_REPLICATION_MAX_ENTRIES}. Must be at least 1. */
|
|
26
|
+
maxEntries?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const SHORTFALL_QUORUMS: readonly ShortfallQuorum[] = ['majority', 'local', 'unrouted'];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* {@link IUnderReplicationLedger} over the portable {@link IKVStore}, the same shape
|
|
33
|
+
* `PersistentTransactionStateStore` uses for its node-local state.
|
|
34
|
+
*
|
|
35
|
+
* Two pieces of in-memory state sit beside the store, neither authoritative:
|
|
36
|
+
* - a per-block operation queue, so each read-compare-write is atomic against the others for the
|
|
37
|
+
* same block (`IKVStore` has no compare-and-set);
|
|
38
|
+
* - an eviction index of recorded block ids in oldest-first order, loaded lazily from the store on
|
|
39
|
+
* the first mutation, so enforcing `maxEntries` costs neither a directory listing nor a read per
|
|
40
|
+
* record. It is only as good as this instance's ownership of the key prefix — the reason the
|
|
41
|
+
* interface scopes its guarantees to one instance per store.
|
|
42
|
+
*/
|
|
43
|
+
export class KvUnderReplicationLedger implements IUnderReplicationLedger {
|
|
44
|
+
private readonly maxEntries: number;
|
|
45
|
+
private readonly queues = new Map<BlockId, Promise<void>>();
|
|
46
|
+
private indexLoad: Promise<Set<BlockId>> | undefined;
|
|
47
|
+
|
|
48
|
+
constructor(private readonly kv: IKVStore, options?: KvUnderReplicationLedgerOptions) {
|
|
49
|
+
const maxEntries = options?.maxEntries ?? DEFAULT_UNDER_REPLICATION_MAX_ENTRIES;
|
|
50
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1) {
|
|
51
|
+
throw new Error(`KvUnderReplicationLedger: maxEntries must be a positive integer, got ${maxEntries}`);
|
|
52
|
+
}
|
|
53
|
+
this.maxEntries = maxEntries;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async record(entry: UnderReplicatedEntry): Promise<void> {
|
|
57
|
+
await this.serialized(entry.blockId, async () => {
|
|
58
|
+
const index = await this.index();
|
|
59
|
+
const existing = await this.read(entry.blockId);
|
|
60
|
+
if (existing !== undefined && existing.rev > entry.rev) {
|
|
61
|
+
log('record:skip-lower-rev %o', { blockId: entry.blockId, rev: entry.rev, recordedRev: existing.rev });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const sameShortfall = existing !== undefined && existing.rev === entry.rev;
|
|
65
|
+
const stored: UnderReplicatedEntry = sameShortfall
|
|
66
|
+
? { ...entry, recordedAt: existing.recordedAt, attempts: existing.attempts }
|
|
67
|
+
: entry;
|
|
68
|
+
await this.kv.set(keyFor(entry.blockId), JSON.stringify(stored));
|
|
69
|
+
if (!sameShortfall) {
|
|
70
|
+
// A new shortfall is the newest one: move it to the young end of the eviction order.
|
|
71
|
+
index.delete(entry.blockId);
|
|
72
|
+
index.add(entry.blockId);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
await this.evictBeyondCap();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async settle(blockId: BlockId, rev: number): Promise<void> {
|
|
79
|
+
await this.serialized(blockId, async () => {
|
|
80
|
+
// Every fully replicated commit settles each of its blocks, and almost none has an entry:
|
|
81
|
+
// answer those from the index instead of a store read. An entry missing from the index is
|
|
82
|
+
// one `read` would report absent anyway (malformed), so nothing is skipped that could be settled.
|
|
83
|
+
const index = await this.index();
|
|
84
|
+
if (!index.has(blockId)) return;
|
|
85
|
+
const existing = await this.read(blockId);
|
|
86
|
+
if (existing === undefined) return;
|
|
87
|
+
if (existing.rev > rev) {
|
|
88
|
+
log('settle:keep-higher-rev %o', { blockId, rev, recordedRev: existing.rev });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
await this.remove(blockId);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async get(blockId: BlockId): Promise<UnderReplicatedEntry | undefined> {
|
|
96
|
+
return this.read(blockId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A full scan. The FIRST scan after start also becomes the eviction index's load, so the drain's
|
|
101
|
+
* start pass warms the index for free and the first commit after a restart finds it loaded
|
|
102
|
+
* instead of paying for the scan itself.
|
|
103
|
+
*/
|
|
104
|
+
async list(): Promise<UnderReplicatedEntry[]> {
|
|
105
|
+
if (this.indexLoad === undefined) {
|
|
106
|
+
const scan = this.scan();
|
|
107
|
+
this.indexLoad = this.indexFrom(scan);
|
|
108
|
+
return scan;
|
|
109
|
+
}
|
|
110
|
+
return this.scan();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async size(): Promise<number> {
|
|
114
|
+
return (await this.index()).size;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async scan(): Promise<UnderReplicatedEntry[]> {
|
|
118
|
+
const keys = await this.kv.list(UNDER_REPLICATED_KEY_PREFIX);
|
|
119
|
+
const entries: UnderReplicatedEntry[] = [];
|
|
120
|
+
// Sequential on purpose: a ledger at its cap under `FileKVStore` is that many files, and
|
|
121
|
+
// opening them all at once would trade a slower scan for file-descriptor exhaustion.
|
|
122
|
+
for (const key of keys) {
|
|
123
|
+
const entry = await this.read(key.slice(UNDER_REPLICATED_KEY_PREFIX.length) as BlockId);
|
|
124
|
+
if (entry !== undefined) entries.push(entry);
|
|
125
|
+
}
|
|
126
|
+
return entries.sort((a, b) => a.recordedAt - b.recordedAt);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async satisfy(blockId: BlockId, peerIds: readonly string[], heldRev?: number): Promise<UnderReplicatedEntry | undefined> {
|
|
130
|
+
return this.serialized(blockId, async () => {
|
|
131
|
+
const existing = await this.read(blockId);
|
|
132
|
+
if (existing === undefined) return undefined;
|
|
133
|
+
if (heldRev !== undefined && existing.rev > heldRev) {
|
|
134
|
+
log('satisfy:keep-higher-rev %o', { blockId, heldRev, recordedRev: existing.rev });
|
|
135
|
+
return existing;
|
|
136
|
+
}
|
|
137
|
+
// Unknown is not empty: see `IUnderReplicationLedger.satisfy`.
|
|
138
|
+
if (existing.missingPeerIds.length === 0) return existing;
|
|
139
|
+
const confirmed = new Set(peerIds);
|
|
140
|
+
const remaining = existing.missingPeerIds.filter(peerId => !confirmed.has(peerId));
|
|
141
|
+
if (remaining.length === 0) {
|
|
142
|
+
await this.remove(blockId);
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (remaining.length === existing.missingPeerIds.length) return existing;
|
|
146
|
+
const updated: UnderReplicatedEntry = { ...existing, missingPeerIds: remaining };
|
|
147
|
+
await this.kv.set(keyFor(blockId), JSON.stringify(updated));
|
|
148
|
+
return updated;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async name(blockId: BlockId, rev: number, missingPeerIds: readonly string[]): Promise<UnderReplicatedEntry | undefined> {
|
|
153
|
+
return this.serialized(blockId, async () => {
|
|
154
|
+
const existing = await this.read(blockId);
|
|
155
|
+
if (existing === undefined) return undefined;
|
|
156
|
+
if (existing.rev !== rev) {
|
|
157
|
+
log('name:keep-other-rev %o', { blockId, rev, recordedRev: existing.rev });
|
|
158
|
+
return existing;
|
|
159
|
+
}
|
|
160
|
+
if (existing.missingPeerIds.length > 0) return existing;
|
|
161
|
+
const updated: UnderReplicatedEntry = { ...existing, missingPeerIds: [...missingPeerIds] };
|
|
162
|
+
await this.kv.set(keyFor(blockId), JSON.stringify(updated));
|
|
163
|
+
return updated;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async noteAttempt(blockId: BlockId): Promise<void> {
|
|
168
|
+
await this.serialized(blockId, async () => {
|
|
169
|
+
const existing = await this.read(blockId);
|
|
170
|
+
if (existing === undefined) return;
|
|
171
|
+
const updated: UnderReplicatedEntry = { ...existing, attempts: existing.attempts + 1 };
|
|
172
|
+
await this.kv.set(keyFor(blockId), JSON.stringify(updated));
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async delete(blockId: BlockId): Promise<void> {
|
|
177
|
+
await this.serialized(blockId, () => this.remove(blockId));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Delete from the store and the eviction index. Callers hold the block's queue. */
|
|
181
|
+
private async remove(blockId: BlockId): Promise<void> {
|
|
182
|
+
const index = await this.index();
|
|
183
|
+
await this.kv.delete(keyFor(blockId));
|
|
184
|
+
index.delete(blockId);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Evict oldest-recorded entries until the ledger is back within `maxEntries`. Runs AFTER the
|
|
189
|
+
* recording block's queue is released and takes each victim's own queue, so no call ever holds
|
|
190
|
+
* two blocks' queues at once and two recordings evicting each other cannot deadlock. A victim a
|
|
191
|
+
* concurrent operation already removed is skipped, and the loop re-measures, so concurrent
|
|
192
|
+
* recordings at the cap never evict more than the overflow.
|
|
193
|
+
*
|
|
194
|
+
* An eviction failure is logged and ends the pass rather than failing the recording that
|
|
195
|
+
* triggered it: that entry is already written, and the next recording tries again.
|
|
196
|
+
*/
|
|
197
|
+
private async evictBeyondCap(): Promise<void> {
|
|
198
|
+
const index = await this.index();
|
|
199
|
+
while (index.size > this.maxEntries) {
|
|
200
|
+
const victim = index.values().next().value;
|
|
201
|
+
if (victim === undefined) return;
|
|
202
|
+
try {
|
|
203
|
+
await this.serialized(victim, async () => {
|
|
204
|
+
if (!index.has(victim)) return;
|
|
205
|
+
await this.remove(victim);
|
|
206
|
+
log('evict:over-cap %o', { blockId: victim, maxEntries: this.maxEntries });
|
|
207
|
+
});
|
|
208
|
+
} catch (err) {
|
|
209
|
+
log.error('evict:failed %o', { blockId: victim, error: (err as Error).message });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The eviction index, loaded once from the store: every entry's block id, oldest `recordedAt`
|
|
217
|
+
* first. A failed load is not cached, so the next mutation retries it instead of the ledger
|
|
218
|
+
* failing every write for the life of the process.
|
|
219
|
+
*
|
|
220
|
+
* NOTE: the load is one read per stored entry. It runs on the first `list`, `size` or mutation
|
|
221
|
+
* after start. With the drain wired that is its start pass on every node — `list` when a pushable
|
|
222
|
+
* peer is connected, otherwise the `size` that decides whether to arm its re-check — so the load
|
|
223
|
+
* runs at boot, off the commit path; without the drain it is the first commit. A solo node keeps
|
|
224
|
+
* an entry per block it writes, so a commit that arrives before the load finishes still waits on
|
|
225
|
+
* it and can stall on slow storage — see
|
|
226
|
+
* `backlog/debt-solo-node-ledger-is-reread-whole-on-first-commit-after-restart`.
|
|
227
|
+
*/
|
|
228
|
+
private index(): Promise<Set<BlockId>> {
|
|
229
|
+
if (this.indexLoad === undefined) {
|
|
230
|
+
this.indexLoad = this.indexFrom(this.scan());
|
|
231
|
+
}
|
|
232
|
+
return this.indexLoad;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private indexFrom(scan: Promise<UnderReplicatedEntry[]>): Promise<Set<BlockId>> {
|
|
236
|
+
return scan
|
|
237
|
+
.then(entries => new Set(entries.map(entry => entry.blockId)))
|
|
238
|
+
.catch((err: unknown) => {
|
|
239
|
+
this.indexLoad = undefined;
|
|
240
|
+
throw err;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async read(blockId: BlockId): Promise<UnderReplicatedEntry | undefined> {
|
|
245
|
+
const raw = await this.kv.get(keyFor(blockId));
|
|
246
|
+
return raw === undefined ? undefined : parseEntry(blockId, raw);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Run `operation` after every earlier operation queued for `blockId`, whatever their outcome.
|
|
251
|
+
* The queue entry is dropped once nothing is waiting behind it, so the map holds only blocks with
|
|
252
|
+
* work in flight.
|
|
253
|
+
*/
|
|
254
|
+
private async serialized<T>(blockId: BlockId, operation: () => Promise<T>): Promise<T> {
|
|
255
|
+
const previous = this.queues.get(blockId) ?? Promise.resolve();
|
|
256
|
+
const run = previous.then(operation);
|
|
257
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
258
|
+
this.queues.set(blockId, tail);
|
|
259
|
+
try {
|
|
260
|
+
return await run;
|
|
261
|
+
} finally {
|
|
262
|
+
if (this.queues.get(blockId) === tail) this.queues.delete(blockId);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function keyFor(blockId: BlockId): string {
|
|
268
|
+
return `${UNDER_REPLICATED_KEY_PREFIX}${blockId}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* An entry as stored, or `undefined` for one that is not a well-formed entry for `blockId`. Stored
|
|
273
|
+
* bytes are this class's own output, but they outlive the process that wrote them, so they are
|
|
274
|
+
* checked rather than cast. A malformed entry is logged and read as absent — never deleted by a
|
|
275
|
+
* read — and the next recording for the block overwrites it.
|
|
276
|
+
*/
|
|
277
|
+
function parseEntry(blockId: BlockId, raw: string): UnderReplicatedEntry | undefined {
|
|
278
|
+
let value: unknown;
|
|
279
|
+
try {
|
|
280
|
+
value = JSON.parse(raw);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
log.error('read:unparseable %o', { blockId, error: (err as Error).message });
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
if (!isEntryFor(blockId, value)) {
|
|
286
|
+
log.error('read:malformed %o', { blockId, raw });
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
return value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isEntryFor(blockId: BlockId, value: unknown): value is UnderReplicatedEntry {
|
|
293
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
294
|
+
const entry = value as Record<string, unknown>;
|
|
295
|
+
return entry.blockId === blockId
|
|
296
|
+
&& Number.isInteger(entry.rev)
|
|
297
|
+
&& typeof entry.actionId === 'string'
|
|
298
|
+
&& SHORTFALL_QUORUMS.includes(entry.quorum as ShortfallQuorum)
|
|
299
|
+
&& Array.isArray(entry.missingPeerIds) && entry.missingPeerIds.every(peerId => typeof peerId === 'string')
|
|
300
|
+
&& typeof entry.recordedAt === 'number'
|
|
301
|
+
&& Number.isInteger(entry.attempts);
|
|
302
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { BlockId } from "@optimystic/db-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How long one node's answer to "is this node in the block's cohort?" is reused before it is looked up
|
|
5
|
+
* again. Shared by the two places that ask it — `CoordinatorRepo`'s write-path responsibility check and
|
|
6
|
+
* `RepoService`'s redirect check — as a VALUE, never as a cache: they sit on either side of the served-repo
|
|
7
|
+
* boundary and each keeps its own memo. With one TTL, the two can disagree about a block only inside the
|
|
8
|
+
* same minute after its cohort changed, and in that window the coordinator's refusal is the backstop.
|
|
9
|
+
*/
|
|
10
|
+
export const RESPONSIBILITY_TTL_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Why a node refused a request on responsibility grounds.
|
|
14
|
+
* - `not-responsible`: the cohort lookup answered, and this node is not in the cohort for these blocks.
|
|
15
|
+
* The request was sent to the wrong machine; the writer re-picks inside the cohort.
|
|
16
|
+
* - `undetermined`: the cohort lookup THREW, so this node cannot tell whether it is responsible. A routing
|
|
17
|
+
* fault on this node, not a misrouted request — and not a licence to accept: accepting there is what
|
|
18
|
+
* let a write commit on a phone that consulted nobody (GitHub #19).
|
|
19
|
+
*/
|
|
20
|
+
export type ResponsibilityRefusalKind = 'not-responsible' | 'undetermined';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A write refused because this node is not, or cannot tell whether it is, responsible for the blocks it
|
|
24
|
+
* names. Typed so a caller matches on {@link kind} and {@link blockIds} rather than on message text.
|
|
25
|
+
*
|
|
26
|
+
* Local only: over the repo protocol a thrown error aborts the stream, so a remote writer sees a failed
|
|
27
|
+
* batch, not this class. Either way the writer's transactor excludes the peer and re-picks, which is the
|
|
28
|
+
* whole remedy for both kinds.
|
|
29
|
+
*/
|
|
30
|
+
export class ResponsibilityRefusalError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly kind: ResponsibilityRefusalKind,
|
|
33
|
+
/** Every block the refusal is about — all of them, so one log line names the whole misroute. */
|
|
34
|
+
readonly blockIds: readonly BlockId[],
|
|
35
|
+
/** Extra context for the message; never parsed. */
|
|
36
|
+
detail?: string
|
|
37
|
+
) {
|
|
38
|
+
super(responsibilityRefusalMessage(kind, blockIds, detail));
|
|
39
|
+
this.name = 'ResponsibilityRefusalError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function responsibilityRefusalMessage(kind: ResponsibilityRefusalKind, blockIds: readonly BlockId[], detail: string | undefined): string {
|
|
44
|
+
const head = kind === 'not-responsible'
|
|
45
|
+
? `Not responsible for block(s): ${blockIds.join(', ')}`
|
|
46
|
+
: `Cannot determine responsibility for block(s): ${blockIds.join(', ')}`;
|
|
47
|
+
return detail === undefined ? head : `${head} (${detail})`;
|
|
48
|
+
}
|
package/src/repo/service.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { pipe } from 'it-pipe'
|
|
2
2
|
import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed'
|
|
3
3
|
import type { Startable, Stream, Connection, StreamHandler, PeerId, Libp2p } from '@libp2p/interface'
|
|
4
|
-
import type { IRepo, RepoMessage } from '@optimystic/db-core'
|
|
5
|
-
import { blockIdsForTransforms } from '@optimystic/db-core'
|
|
6
|
-
import {
|
|
4
|
+
import type { IKeyNetwork, IRepo, RepoMessage } from '@optimystic/db-core'
|
|
5
|
+
import { LruMap, blockIdsForTransforms, routingKeyForBlock } from '@optimystic/db-core'
|
|
6
|
+
import { peerIdFromString } from '@libp2p/peer-id'
|
|
7
7
|
import { encodePeers, type RedirectPayload } from './redirect.js'
|
|
8
8
|
import { MAX_BLOCK_MESSAGE_BYTES } from '../protocol-limits.js'
|
|
9
9
|
import type { Uint8ArrayList } from 'uint8arraylist'
|
|
@@ -11,6 +11,8 @@ import { createLogger, type Logger } from '../logger.js'
|
|
|
11
11
|
import { publishableAddrsForPeer, type AddressLog, type DirectionalConnection } from '../peer-address-book.js'
|
|
12
12
|
import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js'
|
|
13
13
|
import { registerProtocolHandler } from '../network/register-protocol-handler.js'
|
|
14
|
+
import type { OptimysticNodeAttachments } from '../optimystic-node.js'
|
|
15
|
+
import { RESPONSIBILITY_TTL_MS } from './responsibility.js'
|
|
14
16
|
|
|
15
17
|
// Define Components interface
|
|
16
18
|
interface BaseComponents {
|
|
@@ -20,13 +22,17 @@ interface BaseComponents {
|
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
25
|
+
/** The one key-network call the redirect check makes. The node's own `Libp2pKeyPeerNetwork` satisfies it. */
|
|
26
|
+
export type ClusterLookup = Pick<IKeyNetwork, 'findCluster'>
|
|
26
27
|
|
|
27
28
|
export type RepoServiceComponents = BaseComponents & {
|
|
28
29
|
repo: IRepo
|
|
29
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Where the redirect check asks who is responsible for a block. Absent → the `keyNetwork` attachment of
|
|
32
|
+
* the node injected by {@link RepoService.setLibp2p}, which is the production source: the same key
|
|
33
|
+
* network the writer's transactor and this node's coordinator ask, so all three apply one rule.
|
|
34
|
+
*/
|
|
35
|
+
keyNetwork?: ClusterLookup
|
|
30
36
|
peerId?: PeerId
|
|
31
37
|
/**
|
|
32
38
|
* Optional resolver for the addresses this node may publish for a redirect target. Async
|
|
@@ -58,12 +64,10 @@ export type RepoServiceInit = InboundStreamAuthorizationInit & {
|
|
|
58
64
|
logPrefix?: string,
|
|
59
65
|
kBucketSize?: number,
|
|
60
66
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* it will redirect requests to closer peers.
|
|
66
|
-
* Default: 1 (only the closest peer handles requests)
|
|
67
|
+
* Small-mesh bypass for the redirect check: when the block's cohort has fewer than this many peers,
|
|
68
|
+
* the request is handled locally without a membership test (and the coordinator's own responsibility
|
|
69
|
+
* check then decides). It does not size the cohort — the key network's `clusterSize` does.
|
|
70
|
+
* Default: 1 (only an empty cohort bypasses)
|
|
67
71
|
*/
|
|
68
72
|
responsibilityK?: number,
|
|
69
73
|
}
|
|
@@ -89,7 +93,7 @@ export class RepoService implements Startable {
|
|
|
89
93
|
* The libp2p node, injected post-construction by the node wiring (see
|
|
90
94
|
* libp2p-node-base.ts, mirroring how `networkManager`/`fret` receive theirs).
|
|
91
95
|
* The libp2p `components.libp2p` proxy does NOT reliably resolve from inside a
|
|
92
|
-
* service at request time, so the redirect path resolves the network
|
|
96
|
+
* service at request time, so the redirect path resolves the key network,
|
|
93
97
|
* self identity, and connection addrs through this explicitly-set reference.
|
|
94
98
|
*/
|
|
95
99
|
private libp2pRef: Libp2p | undefined
|
|
@@ -101,6 +105,16 @@ export class RepoService implements Startable {
|
|
|
101
105
|
* ingress point, instead of keeping it filterable as the single `peer-address-book` namespace.
|
|
102
106
|
*/
|
|
103
107
|
private readonly addressLog: AddressLog
|
|
108
|
+
/**
|
|
109
|
+
* Per block key, the responsible peer ids the key network last named: one cohort lookup per block per
|
|
110
|
+
* minute on the inbound path, instead of one per request on top of the coordinator's own.
|
|
111
|
+
*
|
|
112
|
+
* NOTE: same TTL as `CoordinatorRepo`'s responsibility cache, deliberately not the same cache — the two
|
|
113
|
+
* sit on either side of the served-repo boundary. Their verdicts can therefore disagree about a block
|
|
114
|
+
* only inside the minute after its cohort changes; a request this check wrongly lets through is refused
|
|
115
|
+
* by the coordinator, and one it wrongly redirects is followed by the client to a member that serves it.
|
|
116
|
+
*/
|
|
117
|
+
private readonly responsibleIds = new LruMap<string, { peerIds: readonly string[], expires: number }>(1000)
|
|
104
118
|
|
|
105
119
|
constructor(components: RepoServiceComponents, init: RepoServiceInit = {}) {
|
|
106
120
|
this.components = components
|
|
@@ -159,9 +173,14 @@ export class RepoService implements Startable {
|
|
|
159
173
|
this.running = false
|
|
160
174
|
}
|
|
161
175
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
176
|
+
/**
|
|
177
|
+
* The key network the redirect check asks. Absent only before the node attaches it, which happens after
|
|
178
|
+
* `start()`; a request in that window is handled locally, where the served-repo proxy has no
|
|
179
|
+
* coordinator to hand it to yet either.
|
|
180
|
+
*/
|
|
181
|
+
private getKeyNetwork(): ClusterLookup | undefined {
|
|
182
|
+
if (this.components.keyNetwork) return this.components.keyNetwork
|
|
183
|
+
return (this.getLibp2p() as Partial<OptimysticNodeAttachments> | undefined)?.keyNetwork
|
|
165
184
|
}
|
|
166
185
|
|
|
167
186
|
private getSelfId(): PeerId | undefined {
|
|
@@ -196,7 +215,7 @@ export class RepoService implements Startable {
|
|
|
196
215
|
* - pend → blockIdsForTransforms(transforms)[0]
|
|
197
216
|
* - cancel → actionRef.blockIds[0]
|
|
198
217
|
* - commit → blockIds[0] (CoordinatorRepo.commit anchors consensus on
|
|
199
|
-
*
|
|
218
|
+
* resolveCohort(blockIds[0]) / executeClusterTransaction(blockIds[0]) and guards
|
|
200
219
|
* with verifyResponsibility(blockIds) — NOT tailId; for a per-block commit batch
|
|
201
220
|
* whose blockIds[0] !== tailId, keying on tailId redirected the commit to the
|
|
202
221
|
* collection tail's cluster, which then fails verifyResponsibility for the non-tail block.)
|
|
@@ -224,41 +243,59 @@ export class RepoService implements Startable {
|
|
|
224
243
|
/**
|
|
225
244
|
* Check if this node should redirect the request for a given key.
|
|
226
245
|
* Returns a RedirectPayload if not responsible, null if should handle locally.
|
|
227
|
-
* Also attaches
|
|
246
|
+
* Also attaches the responsible peer ids to the message for downstream use.
|
|
247
|
+
*
|
|
248
|
+
* The responsible set comes from the node's own key network — `findCluster` on the block's routing
|
|
249
|
+
* key, the same call and the same bytes the writer's transactor and this node's `CoordinatorRepo` use —
|
|
250
|
+
* so a correctly routed request is never redirected, including on machines shared by several networks,
|
|
251
|
+
* where FRET's raw cohort would include peers that do not serve this one.
|
|
252
|
+
*
|
|
253
|
+
* A lookup that THROWS splits by operation: a `get` is handled locally (a read is best-effort, and the
|
|
254
|
+
* coordinator flags what it could not confirm); every other operation propagates the error, which
|
|
255
|
+
* aborts the stream so the writer excludes this peer and re-picks — the posture `CoordinatorRepo` takes
|
|
256
|
+
* on the same fault.
|
|
228
257
|
*/
|
|
229
258
|
async checkRedirect(blockKey: string, opName: string, message: RepoMessage): Promise<RedirectPayload | null> {
|
|
230
|
-
const
|
|
231
|
-
if (!
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const selfId = this.getSelfId()
|
|
259
|
+
const keyNetwork = this.getKeyNetwork()
|
|
260
|
+
if (!keyNetwork) return null
|
|
261
|
+
|
|
262
|
+
let cluster: readonly string[]
|
|
263
|
+
try {
|
|
264
|
+
cluster = await this.responsiblePeerIds(keyNetwork, blockKey)
|
|
265
|
+
} catch (err) {
|
|
266
|
+
this.log.error('redirect lookup failed op=%s blockKey=%s - %e', opName, blockKey, err)
|
|
267
|
+
if (opName === 'get') return null
|
|
268
|
+
throw err
|
|
269
|
+
}
|
|
270
|
+
;(message as RepoMessage & { cluster?: string[] }).cluster = [...cluster]
|
|
271
|
+
|
|
272
|
+
const selfId = this.getSelfId()?.toString()
|
|
245
273
|
if (!selfId) return null
|
|
246
274
|
|
|
247
|
-
const isMember = cluster.
|
|
275
|
+
const isMember = cluster.includes(selfId)
|
|
248
276
|
const smallMesh = cluster.length < this.responsibilityK
|
|
249
277
|
|
|
250
278
|
if (!smallMesh && !isMember) {
|
|
251
|
-
const peers = cluster.filter((p: PeerId) => !peersEqual(p, selfId))
|
|
252
279
|
this.log('redirect op=%s blockKey=%s cluster=%d', opName, blockKey, cluster.length)
|
|
253
|
-
return encodePeers(await Promise.all(
|
|
254
|
-
id
|
|
255
|
-
addrs: await this.getPeerAddrs(
|
|
280
|
+
return encodePeers(await Promise.all(cluster.map(async id => ({
|
|
281
|
+
id,
|
|
282
|
+
addrs: await this.getPeerAddrs(peerIdFromString(id))
|
|
256
283
|
}))))
|
|
257
284
|
}
|
|
258
285
|
|
|
259
286
|
return null
|
|
260
287
|
}
|
|
261
288
|
|
|
289
|
+
/** The block's responsible peer ids, memoized per block key for {@link RESPONSIBILITY_TTL_MS}. A throw is never memoized. */
|
|
290
|
+
private async responsiblePeerIds(keyNetwork: ClusterLookup, blockKey: string): Promise<readonly string[]> {
|
|
291
|
+
const now = Date.now()
|
|
292
|
+
const memo = this.responsibleIds.get(blockKey)
|
|
293
|
+
if (memo && memo.expires > now) return memo.peerIds
|
|
294
|
+
const peerIds = Object.keys(await keyNetwork.findCluster(routingKeyForBlock(blockKey)))
|
|
295
|
+
this.responsibleIds.set(blockKey, { peerIds, expires: now + RESPONSIBILITY_TTL_MS })
|
|
296
|
+
return peerIds
|
|
297
|
+
}
|
|
298
|
+
|
|
262
299
|
/**
|
|
263
300
|
* Handle incoming streams on the repo protocol
|
|
264
301
|
*/
|