@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.
Files changed (234) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +73 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +70 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +16 -1
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +73 -72
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts +4 -6
  10. package/dist/src/cluster/client.d.ts.map +1 -1
  11. package/dist/src/cluster/client.js +8 -13
  12. package/dist/src/cluster/client.js.map +1 -1
  13. package/dist/src/cluster/cluster-policy.d.ts +2 -1
  14. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  15. package/dist/src/cluster/cluster-policy.js.map +1 -1
  16. package/dist/src/cluster/cluster-repo.d.ts +155 -9
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +446 -58
  19. package/dist/src/cluster/cluster-repo.js.map +1 -1
  20. package/dist/src/cluster/commit-proof.js +2 -2
  21. package/dist/src/cluster/race-resolution.d.ts +51 -7
  22. package/dist/src/cluster/race-resolution.d.ts.map +1 -1
  23. package/dist/src/cluster/race-resolution.js +71 -7
  24. package/dist/src/cluster/race-resolution.js.map +1 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts +67 -5
  26. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  27. package/dist/src/cluster/rebalance-monitor.js +120 -26
  28. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  29. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  30. package/dist/src/cluster/spread-on-churn.js +34 -47
  31. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  32. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  33. package/dist/src/cohort-topic/host.js +8 -6
  34. package/dist/src/cohort-topic/host.js.map +1 -1
  35. package/dist/src/cohort-topic/membership-source.js +1 -1
  36. package/dist/src/cohort-topic/membership-source.js.map +1 -1
  37. package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -1
  38. package/dist/src/cohort-topic/peer-codec.js +5 -2
  39. package/dist/src/cohort-topic/peer-codec.js.map +1 -1
  40. package/dist/src/cohort-topic/stream-util.d.ts +24 -3
  41. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  42. package/dist/src/cohort-topic/stream-util.js +36 -3
  43. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  44. package/dist/src/cohort-topic/topic-router.d.ts +9 -0
  45. package/dist/src/cohort-topic/topic-router.d.ts.map +1 -1
  46. package/dist/src/cohort-topic/topic-router.js +12 -2
  47. package/dist/src/cohort-topic/topic-router.js.map +1 -1
  48. package/dist/src/dispute/client.d.ts.map +1 -1
  49. package/dist/src/dispute/client.js +15 -3
  50. package/dist/src/dispute/client.js.map +1 -1
  51. package/dist/src/index.d.ts +5 -0
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +5 -0
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/libp2p-key-network.d.ts +78 -17
  56. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  57. package/dist/src/libp2p-key-network.js +234 -147
  58. package/dist/src/libp2p-key-network.js.map +1 -1
  59. package/dist/src/libp2p-node-base.d.ts +35 -1
  60. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  61. package/dist/src/libp2p-node-base.js +151 -26
  62. package/dist/src/libp2p-node-base.js.map +1 -1
  63. package/dist/src/libp2p-node-rn.d.ts +2 -2
  64. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  65. package/dist/src/libp2p-node-rn.js.map +1 -1
  66. package/dist/src/libp2p-node.d.ts +2 -2
  67. package/dist/src/libp2p-node.d.ts.map +1 -1
  68. package/dist/src/libp2p-node.js.map +1 -1
  69. package/dist/src/logger.d.ts.map +1 -1
  70. package/dist/src/logger.js +6 -1
  71. package/dist/src/logger.js.map +1 -1
  72. package/dist/src/matchmaking/query-transport.d.ts +3 -4
  73. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  74. package/dist/src/matchmaking/query-transport.js +9 -9
  75. package/dist/src/matchmaking/query-transport.js.map +1 -1
  76. package/dist/src/network/network-manager-service.d.ts +10 -5
  77. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  78. package/dist/src/network/network-manager-service.js +11 -2
  79. package/dist/src/network/network-manager-service.js.map +1 -1
  80. package/dist/src/network/relay-reservation.d.ts +170 -0
  81. package/dist/src/network/relay-reservation.d.ts.map +1 -0
  82. package/dist/src/network/relay-reservation.js +527 -0
  83. package/dist/src/network/relay-reservation.js.map +1 -0
  84. package/dist/src/pend-validation.d.ts +3 -2
  85. package/dist/src/pend-validation.d.ts.map +1 -1
  86. package/dist/src/pend-validation.js +3 -2
  87. package/dist/src/pend-validation.js.map +1 -1
  88. package/dist/src/protocol-client.d.ts +8 -1
  89. package/dist/src/protocol-client.d.ts.map +1 -1
  90. package/dist/src/protocol-client.js +11 -0
  91. package/dist/src/protocol-client.js.map +1 -1
  92. package/dist/src/reactivity/forwarder-host.d.ts +2 -1
  93. package/dist/src/reactivity/forwarder-host.d.ts.map +1 -1
  94. package/dist/src/reactivity/forwarder-host.js +2 -1
  95. package/dist/src/reactivity/forwarder-host.js.map +1 -1
  96. package/dist/src/reactivity/origination-manager.d.ts +3 -3
  97. package/dist/src/reactivity/origination-manager.js +3 -3
  98. package/dist/src/reactivity/recover-transport.d.ts +22 -14
  99. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  100. package/dist/src/reactivity/recover-transport.js +32 -19
  101. package/dist/src/reactivity/recover-transport.js.map +1 -1
  102. package/dist/src/reactivity/subscription-manager.d.ts +2 -2
  103. package/dist/src/reactivity/topic-bytes.d.ts +6 -7
  104. package/dist/src/reactivity/topic-bytes.d.ts.map +1 -1
  105. package/dist/src/reactivity/topic-bytes.js +8 -9
  106. package/dist/src/reactivity/topic-bytes.js.map +1 -1
  107. package/dist/src/repo/client.d.ts +5 -2
  108. package/dist/src/repo/client.d.ts.map +1 -1
  109. package/dist/src/repo/client.js +49 -40
  110. package/dist/src/repo/client.js.map +1 -1
  111. package/dist/src/repo/cluster-coordinator.d.ts +96 -11
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +164 -25
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +240 -122
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +627 -473
  118. package/dist/src/repo/coordinator-repo.js.map +1 -1
  119. package/dist/src/repo/i-under-replication-ledger.d.ts +87 -0
  120. package/dist/src/repo/i-under-replication-ledger.d.ts.map +1 -0
  121. package/dist/src/repo/i-under-replication-ledger.js +2 -0
  122. package/dist/src/repo/i-under-replication-ledger.js.map +1 -0
  123. package/dist/src/repo/kv-under-replication-ledger.d.ts +91 -0
  124. package/dist/src/repo/kv-under-replication-ledger.d.ts.map +1 -0
  125. package/dist/src/repo/kv-under-replication-ledger.js +287 -0
  126. package/dist/src/repo/kv-under-replication-ledger.js.map +1 -0
  127. package/dist/src/repo/responsibility.d.ts +37 -0
  128. package/dist/src/repo/responsibility.d.ts.map +1 -0
  129. package/dist/src/repo/responsibility.js +37 -0
  130. package/dist/src/repo/responsibility.js.map +1 -0
  131. package/dist/src/repo/service.d.ts +44 -15
  132. package/dist/src/repo/service.d.ts.map +1 -1
  133. package/dist/src/repo/service.js +64 -27
  134. package/dist/src/repo/service.js.map +1 -1
  135. package/dist/src/repo/stuck-reservation.d.ts +127 -0
  136. package/dist/src/repo/stuck-reservation.d.ts.map +1 -0
  137. package/dist/src/repo/stuck-reservation.js +179 -0
  138. package/dist/src/repo/stuck-reservation.js.map +1 -0
  139. package/dist/src/repo/under-replication-drain.d.ts +208 -0
  140. package/dist/src/repo/under-replication-drain.d.ts.map +1 -0
  141. package/dist/src/repo/under-replication-drain.js +461 -0
  142. package/dist/src/repo/under-replication-drain.js.map +1 -0
  143. package/dist/src/rn.d.ts +5 -0
  144. package/dist/src/rn.d.ts.map +1 -1
  145. package/dist/src/rn.js +5 -0
  146. package/dist/src/rn.js.map +1 -1
  147. package/dist/src/storage/block-latch.d.ts +3 -2
  148. package/dist/src/storage/block-latch.d.ts.map +1 -1
  149. package/dist/src/storage/block-latch.js +7 -4
  150. package/dist/src/storage/block-latch.js.map +1 -1
  151. package/dist/src/storage/block-storage.d.ts +45 -3
  152. package/dist/src/storage/block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/block-storage.js +156 -9
  154. package/dist/src/storage/block-storage.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +57 -5
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js.map +1 -1
  158. package/dist/src/storage/pending-claim.d.ts +167 -0
  159. package/dist/src/storage/pending-claim.d.ts.map +1 -0
  160. package/dist/src/storage/pending-claim.js +156 -0
  161. package/dist/src/storage/pending-claim.js.map +1 -0
  162. package/dist/src/storage/raw-store-codec.d.ts +13 -0
  163. package/dist/src/storage/raw-store-codec.d.ts.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +24 -3
  165. package/dist/src/storage/raw-store-codec.js.map +1 -1
  166. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -1
  167. package/dist/src/storage/restoration-coordinator.js +2 -1
  168. package/dist/src/storage/restoration-coordinator.js.map +1 -1
  169. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -1
  170. package/dist/src/storage/ring-shift-coordinator.js +3 -3
  171. package/dist/src/storage/ring-shift-coordinator.js.map +1 -1
  172. package/dist/src/storage/storage-repo.d.ts +122 -5
  173. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  174. package/dist/src/storage/storage-repo.js +245 -68
  175. package/dist/src/storage/storage-repo.js.map +1 -1
  176. package/dist/src/storage/struct.d.ts +58 -0
  177. package/dist/src/storage/struct.d.ts.map +1 -1
  178. package/dist/src/testing/mesh-harness.d.ts +77 -11
  179. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  180. package/dist/src/testing/mesh-harness.js +226 -100
  181. package/dist/src/testing/mesh-harness.js.map +1 -1
  182. package/package.json +2 -2
  183. package/readme.md +27 -3
  184. package/src/cluster/block-transfer-service.ts +132 -4
  185. package/src/cluster/block-transfer.ts +79 -84
  186. package/src/cluster/client.ts +115 -119
  187. package/src/cluster/cluster-policy.ts +2 -1
  188. package/src/cluster/cluster-repo.ts +493 -65
  189. package/src/cluster/commit-proof.ts +2 -2
  190. package/src/cluster/race-resolution.ts +76 -7
  191. package/src/cluster/rebalance-monitor.ts +677 -539
  192. package/src/cluster/spread-on-churn.ts +34 -54
  193. package/src/cohort-topic/host.ts +8 -6
  194. package/src/cohort-topic/membership-source.ts +1 -1
  195. package/src/cohort-topic/peer-codec.ts +34 -31
  196. package/src/cohort-topic/stream-util.ts +39 -4
  197. package/src/cohort-topic/topic-router.ts +12 -2
  198. package/src/dispute/client.ts +17 -7
  199. package/src/index.ts +64 -59
  200. package/src/libp2p-key-network.ts +1377 -1260
  201. package/src/libp2p-node-base.ts +186 -27
  202. package/src/libp2p-node-rn.ts +31 -30
  203. package/src/libp2p-node.ts +37 -36
  204. package/src/logger.ts +6 -1
  205. package/src/matchmaking/query-transport.ts +9 -9
  206. package/src/network/network-manager-service.ts +16 -5
  207. package/src/network/relay-reservation.ts +636 -0
  208. package/src/pend-validation.ts +3 -2
  209. package/src/protocol-client.ts +13 -1
  210. package/src/reactivity/forwarder-host.ts +2 -1
  211. package/src/reactivity/origination-manager.ts +3 -3
  212. package/src/reactivity/recover-transport.ts +39 -22
  213. package/src/reactivity/subscription-manager.ts +2 -2
  214. package/src/reactivity/topic-bytes.ts +35 -37
  215. package/src/repo/client.ts +172 -173
  216. package/src/repo/cluster-coordinator.ts +183 -25
  217. package/src/repo/coordinator-repo.ts +656 -506
  218. package/src/repo/i-under-replication-ledger.ts +89 -0
  219. package/src/repo/kv-under-replication-ledger.ts +302 -0
  220. package/src/repo/responsibility.ts +48 -0
  221. package/src/repo/service.ts +76 -39
  222. package/src/repo/stuck-reservation.ts +224 -0
  223. package/src/repo/under-replication-drain.ts +582 -0
  224. package/src/rn.ts +74 -69
  225. package/src/storage/block-latch.ts +7 -4
  226. package/src/storage/block-storage.ts +161 -10
  227. package/src/storage/i-block-storage.ts +60 -5
  228. package/src/storage/pending-claim.ts +200 -0
  229. package/src/storage/raw-store-codec.ts +26 -3
  230. package/src/storage/restoration-coordinator.ts +212 -211
  231. package/src/storage/ring-shift-coordinator.ts +271 -271
  232. package/src/storage/storage-repo.ts +280 -72
  233. package/src/storage/struct.ts +58 -0
  234. 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
+ }
@@ -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 { peersEqual } from '../peer-utils.js'
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
- export interface NetworkManagerLike {
24
- getCluster(key: Uint8Array): Promise<PeerId[]>
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
- networkManager?: NetworkManagerLike
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
- * Responsibility K - the replica set size for determining cluster membership.
62
- * This is distinct from kBucketSize (DHT routing).
63
- * When set, this determines how many peers (by XOR distance) are considered
64
- * responsible for a key. If this node is not in the top responsibilityK peers,
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 manager,
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
- private getNetworkManager(): NetworkManagerLike | undefined {
163
- if (this.components.networkManager) return this.components.networkManager
164
- return (this.getLibp2p() as any)?.services?.networkManager as NetworkManagerLike | undefined
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
- * getClusterSize(blockIds[0]) / executeClusterTransaction(blockIds[0]) and guards
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 cluster info to the message for downstream use.
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 nm = this.getNetworkManager()
231
- if (!nm) return null
232
-
233
- // Pass the RAW encoded block-key bytes to getCluster. getCluster hashes
234
- // internally (hashKey == sha256), so the responsible-set coordinate becomes
235
- // hashKey(encode(blockKey)) — identical to how the cluster coordinator
236
- // derives it (ClusterCoordinator.getClusterForBlock → findCluster(encode(blockId))).
237
- // Pre-hashing here would double-hash (hashKey(sha256(encode(blockKey)))), placing
238
- // the cohort at an unrelated ring coordinate and redirecting requests the
239
- // coordinator legitimately routed to this peer.
240
- const key = new TextEncoder().encode(blockKey)
241
- const cluster = await nm.getCluster(key)
242
- ;(message as any).cluster = cluster.map((p: PeerId) => p.toString?.() ?? String(p))
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.some((p: PeerId) => peersEqual(p, selfId))
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(peers.map(async (pid: PeerId) => ({
254
- id: pid.toString(),
255
- addrs: await this.getPeerAddrs(pid)
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
  */