@optimystic/db-p2p 1.0.0-beta.2 → 1.0.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 +172 -17
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +553 -149
  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 +241 -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 +19 -7
  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 +119 -5
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +181 -29
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +259 -76
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +716 -328
  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 +137 -5
  173. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  174. package/dist/src/storage/storage-repo.js +273 -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 +92 -11
  179. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  180. package/dist/src/testing/mesh-harness.js +246 -103
  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 +3115 -2671
  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 -1253
  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 +20 -7
  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 +1296 -1113
  217. package/src/repo/coordinator-repo.ts +3087 -2687
  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 +311 -73
  233. package/src/storage/struct.ts +58 -0
  234. package/src/testing/mesh-harness.ts +329 -115
@@ -0,0 +1,582 @@
1
+ import type { Connection, Libp2p, PeerId, Startable } from '@libp2p/interface';
2
+ import type { BlockDurabilityReachedEvent, BlockId, IKeyNetwork } from '@optimystic/db-core';
3
+ import { routingKeyForBlock } from '@optimystic/db-core';
4
+ import type { PartitionDetector } from '../cluster/partition-detector.js';
5
+ import type { PushBlockOutcome } from '../cluster/block-transfer-service.js';
6
+ import { buildBlockTransferProtocol } from '../cluster/block-transfer-service.js';
7
+ import type { IUnderReplicationLedger, UnderReplicatedEntry } from './i-under-replication-ledger.js';
8
+ import { createLogger } from '../logger.js';
9
+
10
+ const log = createLogger('under-replication-drain');
11
+
12
+ export interface UnderReplicationDrainConfig {
13
+ /** Debounce after a connection opens or a peer is identified, before a pass runs. Default: 5000 */
14
+ debounceMs: number;
15
+ /**
16
+ * Minimum interval between passes (ms). A trigger inside the window is DEFERRED to the window's
17
+ * end, never dropped, so a flapping peer costs at most one pass per interval and a peer that
18
+ * arrives mid-window is still drained to. Default: 15000
19
+ */
20
+ minIntervalMs: number;
21
+ /**
22
+ * Self-arming re-check timer while any ledger entry is outstanding (ms). Passes otherwise fire
23
+ * only on connection events, so a failed push on a then-quiet network — or a shortfall recorded
24
+ * while its missing member was already connected — would wait for the next arrival. Cheap while
25
+ * nothing can be pushed: a tick with no pushable peer connected costs one connection scan and
26
+ * one in-memory count. `0` disables. Default: 30000
27
+ */
28
+ recheckIntervalMs: number;
29
+ /**
30
+ * Entries examined per pass. Passes rotate through the ledger, oldest-recorded first, so a large
31
+ * backlog drains one budget per pass and no entry starves behind a stuck one. A deferred entry is
32
+ * not touched at all; it is re-examined on a later pass. Default: 64
33
+ */
34
+ blockBudget: number;
35
+ /**
36
+ * Consecutive rounds a reachable peer may fail to confirm one block before it is ABANDONED for
37
+ * that block — no longer pushed to — until it reconnects, which retries it from scratch. The
38
+ * entry itself stays in the ledger, visible, whatever happens here. Default: 5
39
+ */
40
+ maxAttempts: number;
41
+ }
42
+
43
+ export const DEFAULT_UNDER_REPLICATION_DRAIN_CONFIG: UnderReplicationDrainConfig = {
44
+ debounceMs: 5000,
45
+ minIntervalMs: 15_000,
46
+ recheckIntervalMs: 30_000,
47
+ blockBudget: 64,
48
+ maxAttempts: 5
49
+ };
50
+
51
+ /**
52
+ * The one method the drain may call on the node's storage repo: emit a full-replication event.
53
+ * Handed in as a function, exactly as the cluster side receives a `CommitCertificateSink`, so the
54
+ * drain has no way to reach anything else on the repo.
55
+ */
56
+ export type BlockDurabilitySink = (event: BlockDurabilityReachedEvent) => void;
57
+
58
+ /**
59
+ * The push primitive, already bound to this node's own store, dialer and protocol prefix — the
60
+ * node binds `pushBlockToPeers` (`cluster/block-transfer-service.ts`); a unit test binds a double.
61
+ */
62
+ export type BoundBlockPusher = (blockId: BlockId, peerIds: readonly string[]) => Promise<PushBlockOutcome>;
63
+
64
+ /** The slice of libp2p the drain uses: identity, the connection events, and who is connected. */
65
+ export type DrainLibp2p = Pick<Libp2p, 'peerId' | 'addEventListener' | 'removeEventListener' | 'getConnections'> & {
66
+ peerStore: Pick<Libp2p['peerStore'], 'get'>;
67
+ };
68
+
69
+ export interface UnderReplicationDrainDeps {
70
+ libp2p: DrainLibp2p;
71
+ ledger: IUnderReplicationLedger;
72
+ /** Re-resolves a block's cohort for an entry that could not name its missing members. */
73
+ keyNetwork: Pick<IKeyNetwork, 'findCluster'>;
74
+ partitionDetector: Pick<PartitionDetector, 'detectPartition'>;
75
+ pushBlock: BoundBlockPusher;
76
+ emit: BlockDurabilitySink;
77
+ /** The `/optimystic/<networkName>` prefix; a connected peer counts as pushable only once identify
78
+ * reports it serving this network's block-transfer protocol. Default: none (any connected peer). */
79
+ protocolPrefix?: string;
80
+ }
81
+
82
+ /** What woke a pass. */
83
+ export type DrainTrigger = 'start' | 'connection' | 'identify' | 'recheck' | 'manual';
84
+
85
+ /** What one pass did. Counts are per ledger ENTRY (one per block). */
86
+ export interface DrainPassResult {
87
+ trigger: DrainTrigger;
88
+ /** Set when the pass did no work at all, and why. */
89
+ skipped?: 'partition' | 'no-pushable-peer';
90
+ /** Entries in the ledger when the pass began. */
91
+ entries: number;
92
+ /** Entries examined this pass, up to the block budget. */
93
+ examined: number;
94
+ /** Entries the budget left for a later pass. */
95
+ deferred: number;
96
+ /** Entries whose cohort resolves to this node alone: nothing owed, nothing counted. */
97
+ solo: number;
98
+ /** Entries owing a copy to nobody currently pushable (not connected, not yet identified, or
99
+ * abandoned) — or whose cohort could not be resolved this pass. */
100
+ waiting: number;
101
+ /** Entries pushed to at least one peer. */
102
+ pushed: number;
103
+ /** Entries whose last shortfall cleared — one full-replication event fired for each. */
104
+ cleared: number;
105
+ /** Entries deleted because the block is gone from local storage. */
106
+ dropped: number;
107
+ /** Peers newly abandoned for a block this pass. */
108
+ abandoned: number;
109
+ }
110
+
111
+ /** Who an entry still owes a copy to, or that it owes nobody because the node is alone. */
112
+ type Owed =
113
+ | { kind: 'solo' }
114
+ | { kind: 'unresolved' }
115
+ | { kind: 'peers'; peerIds: string[]; named: boolean };
116
+
117
+ const zeroCounts = (trigger: DrainTrigger): DrainPassResult => ({
118
+ trigger, entries: 0, examined: 0, deferred: 0, solo: 0, waiting: 0, pushed: 0, cleared: 0, dropped: 0, abandoned: 0
119
+ });
120
+
121
+ /**
122
+ * Sends the copies the under-replication ledger says this node still owes, and says when a block
123
+ * finally has all of them.
124
+ *
125
+ * `CoordinatorRepo.commit` writes an entry per block it acknowledged below full replication, naming
126
+ * the cohort members that had not confirmed it (or an EMPTY set when no cohort could be named — a
127
+ * `local` or `unrouted` write). This service is the other half: on start, whenever a peer connects or
128
+ * is identified, and on a re-check timer while anything is outstanding, it walks the ledger and pushes
129
+ * each block to the owed peers that are reachable right now. A peer that confirms is removed from the
130
+ * entry; when the entry empties it is deleted and ONE {@link BlockDurabilityReachedEvent} fires through
131
+ * the sink — after the deletion, never before.
132
+ *
133
+ * What it deliberately does not do:
134
+ * - **Dial the world.** A copy goes only to a peer that is connected and identified as serving this
135
+ * network. An entry whose owed peers are all away simply waits; the next arrival wakes the drain.
136
+ * - **Re-resolve a NAMED peer.** A `majority` entry names members that were in the cohort when the
137
+ * write was acknowledged; a copy there is never harmful even if the cohort has since moved on.
138
+ * - **Count an attempt against a node that is alone.** An entry with no named members whose cohort
139
+ * still resolves to this node alone is left exactly as it is: nobody is owed anything yet.
140
+ * - **Delete an entry to tidy up.** Only a confirmed copy (or the block leaving local storage) removes
141
+ * one. A peer that never returns leaves its entry in place; the ledger's size cap is the backstop.
142
+ * - **Coordinate with the rebalance growth arm or spread-on-churn.** All three may push the same
143
+ * block to the same peer; the receiver is idempotent, so the overlap costs duplicate work only.
144
+ *
145
+ * NOTE: a pass that finds work SCANS the ledger (`IUnderReplicationLedger.list`, one store read per
146
+ * entry) and examines up to `blockBudget` entries, oldest first. Bounded by the node's owned-block
147
+ * count today. If a node ever holds so many under-replicated blocks that the scan shows up — a long
148
+ * solo run with a persistent store — index the ledger by missing peer instead of scanning, and
149
+ * consider draining newest-first so a host's most recent pending writes settle before old ones.
150
+ */
151
+ export class UnderReplicationDrain implements Startable {
152
+ private running = false;
153
+ private readonly config: UnderReplicationDrainConfig;
154
+ private readonly blockTransferProtocol: string;
155
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
156
+ private timer: ReturnType<typeof setTimeout> | null = null;
157
+ private timerDueAt = 0;
158
+ private lastPassAt = 0;
159
+ private passInFlight: Promise<DrainPassResult> | null = null;
160
+ private pendingTrigger: DrainTrigger | null = null;
161
+ /** Rotation cursor into the oldest-first entry list, so a backlog wider than the budget is
162
+ * swept in full over successive passes rather than the same oldest slice every time. */
163
+ private cursor = 0;
164
+ /** Consecutive unconfirmed rounds per (block, peer) — the give-up counter. */
165
+ private readonly failures = new Map<BlockId, Map<string, number>>();
166
+ /** Peers given up on per block; excluded from pushes until they reconnect. */
167
+ private readonly abandoned = new Map<BlockId, Set<string>>();
168
+
169
+ private readonly onConnectionOpen: (event: CustomEvent<Connection>) => void;
170
+ private readonly onPeerIdentify: () => void;
171
+
172
+ constructor(
173
+ private readonly deps: UnderReplicationDrainDeps,
174
+ config: Partial<UnderReplicationDrainConfig> = {}
175
+ ) {
176
+ this.config = { ...DEFAULT_UNDER_REPLICATION_DRAIN_CONFIG, ...config };
177
+ this.blockTransferProtocol = buildBlockTransferProtocol(deps.protocolPrefix ?? '');
178
+ this.onConnectionOpen = (event) => this.handleConnectionOpen(event);
179
+ this.onPeerIdentify = () => this.schedule('identify');
180
+ }
181
+
182
+ // ── Startable ────────────────────────────────────────────────────
183
+
184
+ async start(): Promise<void> {
185
+ if (this.running) return;
186
+ this.running = true;
187
+ this.deps.libp2p.addEventListener('connection:open', this.onConnectionOpen);
188
+ this.deps.libp2p.addEventListener('peer:identify', this.onPeerIdentify);
189
+ log('started');
190
+ // The one case no in-memory mechanism ever covered: entries the ledger carried across a
191
+ // restart. Off the start path so a slow scan never blocks the node coming up.
192
+ void this.run('start').catch(err => { log('start pass error: %o', err); });
193
+ }
194
+
195
+ async stop(): Promise<void> {
196
+ if (!this.running) return;
197
+ this.running = false;
198
+ this.deps.libp2p.removeEventListener('connection:open', this.onConnectionOpen);
199
+ this.deps.libp2p.removeEventListener('peer:identify', this.onPeerIdentify);
200
+ this.clearDebounce();
201
+ this.clearTimer();
202
+ this.pendingTrigger = null;
203
+ // An in-flight pass checks `running` between entries and bails; it is not awaited here so a
204
+ // stop never waits out a push deadline.
205
+ log('stopped');
206
+ }
207
+
208
+ // ── Public API ───────────────────────────────────────────────────
209
+
210
+ /** Run one pass now, bypassing the debounce and the interval throttle. Waits for an in-flight pass first. */
211
+ async checkNow(): Promise<DrainPassResult> {
212
+ if (this.passInFlight) await this.passInFlight.catch(() => undefined);
213
+ return this.run('manual');
214
+ }
215
+
216
+ /** Observability: given-up (block, peer) pairs, whether a timer is armed, and the last pass. */
217
+ getDiagnostics(): { abandonedPairs: number; timerArmed: boolean; lastPassAt: number } {
218
+ let abandonedPairs = 0;
219
+ for (const peers of this.abandoned.values()) abandonedPairs += peers.size;
220
+ return { abandonedPairs, timerArmed: this.timer !== null, lastPassAt: this.lastPassAt };
221
+ }
222
+
223
+ // ── Scheduling ───────────────────────────────────────────────────
224
+
225
+ private handleConnectionOpen(event: CustomEvent<Connection>): void {
226
+ if (!this.running) return;
227
+ // A peer that comes back is retried from scratch: its abandonment and give-up counts go.
228
+ // Only a FRESH connection counts — libp2p opens parallel connections to one peer, and a second
229
+ // one must not wipe the record of a peer that never went away.
230
+ // NOTE: "fresh" is read as "this is the peer's only connection", which relies on libp2p's
231
+ // connection manager having registered the new connection before this listener runs. True on
232
+ // libp2p 3.x: the manager subscribes to `connection:open` at its own start (before any user
233
+ // listener) and stores the connection synchronously, before its first await. If a libp2p
234
+ // upgrade ever defers that store, every parallel connection would look fresh and an abandoned
235
+ // peer would be retried on each one — cheap, not wrong — and the check would want the
236
+ // `peer:connect` event instead, which fires only for a peer's first connection.
237
+ const remotePeer = event?.detail?.remotePeer;
238
+ if (remotePeer !== undefined && this.deps.libp2p.getConnections(remotePeer).length <= 1) {
239
+ this.forgetPeer(remotePeer.toString());
240
+ }
241
+ this.schedule('connection');
242
+ }
243
+
244
+ private schedule(trigger: DrainTrigger): void {
245
+ if (!this.running) return;
246
+ this.clearDebounce();
247
+ this.debounceTimer = setTimeout(() => {
248
+ this.debounceTimer = null;
249
+ this.maybeRun(trigger);
250
+ }, this.config.debounceMs);
251
+ unref(this.debounceTimer);
252
+ }
253
+
254
+ /** Run now, or defer to the end of the minimum interval — a trigger is never dropped. */
255
+ private maybeRun(trigger: DrainTrigger): void {
256
+ if (!this.running) return;
257
+ const wait = this.lastPassAt + this.config.minIntervalMs - Date.now();
258
+ if (wait > 0) {
259
+ log('throttled trigger=%s wait=%dms', trigger, wait);
260
+ this.armTimer(wait, trigger);
261
+ return;
262
+ }
263
+ void this.run(trigger).catch(err => { log('pass error trigger=%s: %o', trigger, err); });
264
+ }
265
+
266
+ /** Arm the one timer, pulling an already-armed one EARLIER when `delayMs` asks for sooner. */
267
+ private armTimer(delayMs: number, trigger: DrainTrigger): void {
268
+ if (!this.running) return;
269
+ const dueAt = Date.now() + delayMs;
270
+ if (this.timer !== null) {
271
+ if (this.timerDueAt <= dueAt) return;
272
+ this.clearTimer();
273
+ }
274
+ this.timerDueAt = dueAt;
275
+ this.timer = setTimeout(() => {
276
+ this.timer = null;
277
+ this.maybeRun(trigger);
278
+ }, delayMs);
279
+ unref(this.timer);
280
+ }
281
+
282
+ private clearTimer(): void {
283
+ if (this.timer !== null) {
284
+ clearTimeout(this.timer);
285
+ this.timer = null;
286
+ }
287
+ }
288
+
289
+ private clearDebounce(): void {
290
+ if (this.debounceTimer !== null) {
291
+ clearTimeout(this.debounceTimer);
292
+ this.debounceTimer = null;
293
+ }
294
+ }
295
+
296
+ /** Arm the re-check while anything is outstanding; otherwise let the timer lapse. */
297
+ private async rearmRecheck(): Promise<void> {
298
+ if (!this.running || this.config.recheckIntervalMs <= 0) return;
299
+ const outstanding = await this.deps.ledger.size();
300
+ if (outstanding > 0) this.armTimer(this.config.recheckIntervalMs, 'recheck');
301
+ }
302
+
303
+ // ── The pass ─────────────────────────────────────────────────────
304
+
305
+ /** One pass, never overlapping another: a trigger that lands mid-pass runs one more afterwards. */
306
+ private run(trigger: DrainTrigger): Promise<DrainPassResult> {
307
+ if (this.passInFlight) {
308
+ this.pendingTrigger = trigger;
309
+ return this.passInFlight;
310
+ }
311
+ const pass = this.performPass(trigger).finally(() => {
312
+ this.passInFlight = null;
313
+ const pending = this.pendingTrigger;
314
+ this.pendingTrigger = null;
315
+ if (pending !== null) this.maybeRun(pending);
316
+ });
317
+ this.passInFlight = pass;
318
+ return pass;
319
+ }
320
+
321
+ private async performPass(trigger: DrainTrigger): Promise<DrainPassResult> {
322
+ const counts = zeroCounts(trigger);
323
+ // A pass that scanned counts for the throttle however it ends — a ledger fault mid-pass must
324
+ // not make the next trigger an unthrottled retry. A skipped pass is free and does not count.
325
+ let scanned = false;
326
+ try {
327
+ if (this.deps.partitionDetector.detectPartition()) {
328
+ log('pass:skip trigger=%s reason=partition', trigger);
329
+ return { ...counts, skipped: 'partition' };
330
+ }
331
+ const pushable = await this.pushablePeers();
332
+ this.forgetDisconnected(pushable);
333
+ if (pushable.size === 0) {
334
+ return { ...counts, skipped: 'no-pushable-peer' };
335
+ }
336
+
337
+ const entries = await this.deps.ledger.list();
338
+ scanned = true;
339
+ this.pruneState(entries);
340
+ counts.entries = entries.length;
341
+ const examined = this.takeBudget(entries);
342
+ counts.examined = examined.length;
343
+ counts.deferred = entries.length - examined.length;
344
+
345
+ for (const entry of examined) {
346
+ if (!this.running) break;
347
+ await this.drainEntry(entry, pushable, counts);
348
+ }
349
+ log('pass:done trigger=%s entries=%d examined=%d deferred=%d solo=%d waiting=%d pushed=%d cleared=%d dropped=%d abandoned=%d',
350
+ trigger, counts.entries, counts.examined, counts.deferred, counts.solo, counts.waiting,
351
+ counts.pushed, counts.cleared, counts.dropped, counts.abandoned);
352
+ return counts;
353
+ } finally {
354
+ if (scanned) this.lastPassAt = Date.now();
355
+ await this.rearmRecheck();
356
+ }
357
+ }
358
+
359
+ /** The next `blockBudget` entries from the rotation cursor, wrapping; the cursor advances past them. */
360
+ private takeBudget(entries: UnderReplicatedEntry[]): UnderReplicatedEntry[] {
361
+ const budget = Math.max(1, this.config.blockBudget);
362
+ if (entries.length <= budget) {
363
+ this.cursor = 0;
364
+ return entries;
365
+ }
366
+ const start = this.cursor % entries.length;
367
+ const taken = [...entries.slice(start, start + budget), ...entries.slice(0, Math.max(0, start + budget - entries.length))];
368
+ this.cursor = (start + budget) % entries.length;
369
+ return taken;
370
+ }
371
+
372
+ private async drainEntry(entry: UnderReplicatedEntry, pushable: Set<string>, counts: DrainPassResult): Promise<void> {
373
+ const owed = await this.owedPeers(entry);
374
+ if (owed.kind === 'solo') {
375
+ counts.solo++;
376
+ return;
377
+ }
378
+ if (owed.kind === 'unresolved') {
379
+ counts.waiting++;
380
+ return;
381
+ }
382
+ const abandoned = this.abandoned.get(entry.blockId);
383
+ const reachable = owed.peerIds.filter(peerId => pushable.has(peerId) && !abandoned?.has(peerId));
384
+ if (reachable.length === 0) {
385
+ counts.waiting++;
386
+ return;
387
+ }
388
+
389
+ const outcome = await this.deps.pushBlock(entry.blockId, reachable);
390
+ if (outcome.status === 'no-local-data') {
391
+ // A block this node no longer holds is not one it can owe — the same call spread-on-churn
392
+ // makes when a tracked block has left local storage.
393
+ log('entry:dropped block=%s rev=%d (no local data)', entry.blockId, entry.rev);
394
+ await this.deps.ledger.delete(entry.blockId);
395
+ this.forgetBlock(entry.blockId);
396
+ counts.dropped++;
397
+ return;
398
+ }
399
+ if (outcome.status === 'unavailable') {
400
+ // Could not find out what this node holds — not "holds nothing". Keep the entry, count nothing.
401
+ log('entry:unavailable block=%s rev=%d reason=%s (keeping)', entry.blockId, entry.rev, outcome.reason);
402
+ counts.waiting++;
403
+ return;
404
+ }
405
+ counts.pushed++;
406
+ const heldRev = outcome.latest?.rev ?? entry.rev;
407
+ for (const peerId of outcome.confirmed) {
408
+ log('push:ok block=%s peer=%s rev=%d', entry.blockId, peerId, heldRev);
409
+ this.failures.get(entry.blockId)?.delete(peerId);
410
+ }
411
+
412
+ if (outcome.confirmed.length > 0) {
413
+ const cleared = await this.recordConfirmed(entry, owed, outcome.confirmed, heldRev);
414
+ if (cleared) {
415
+ counts.cleared++;
416
+ this.forgetBlock(entry.blockId);
417
+ // After the deletion, never before: a listener that re-reads the ledger sees it gone.
418
+ this.deps.emit({ blockIds: [entry.blockId], rev: entry.rev, actionId: entry.actionId, collectionId: outcome.collectionId });
419
+ return;
420
+ }
421
+ }
422
+ if (outcome.refusals.length > 0) {
423
+ await this.deps.ledger.noteAttempt(entry.blockId);
424
+ counts.abandoned += this.noteRefusals(entry, outcome);
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Take the confirmed peers off the entry. An entry that could not name its members is named now,
430
+ * at its own revision, with the cohort resolved for this push — so `satisfy` has a set to shrink.
431
+ * Both writes carry the revision guard: a newer shortfall recorded while the push was in flight
432
+ * is left alone (`name` touches only its own revision; `satisfy` keeps a higher one), and neither
433
+ * recreates an entry a concurrent `settle` removed mid-push.
434
+ *
435
+ * NOTE: an entry settled mid-push by a `full` commit at a newer revision is gone by the time
436
+ * `satisfy` runs, which reports that as "gone", so the event fires here too. It is true — the block
437
+ * IS fully replicated — but under the older `rev`/`actionId`, and a listener may also have heard
438
+ * `full` on that commit's own result. If a host ever needs the two told apart, have `satisfy`
439
+ * distinguish "deleted now" from "absent".
440
+ *
441
+ * @returns whether the entry is gone — its last missing member confirmed.
442
+ */
443
+ private async recordConfirmed(entry: UnderReplicatedEntry, owed: Owed & { kind: 'peers' }, confirmed: string[], heldRev: number): Promise<boolean> {
444
+ if (!owed.named) {
445
+ await this.deps.ledger.name(entry.blockId, entry.rev, owed.peerIds);
446
+ }
447
+ const remaining = await this.deps.ledger.satisfy(entry.blockId, confirmed, heldRev);
448
+ if (remaining === undefined) {
449
+ log('entry:cleared block=%s rev=%d action=%s', entry.blockId, entry.rev, entry.actionId);
450
+ return true;
451
+ }
452
+ return false;
453
+ }
454
+
455
+ /** Log each refusal so "cannot place" reads differently from "cannot reach", and count the round. */
456
+ private noteRefusals(entry: UnderReplicatedEntry, outcome: PushBlockOutcome & { status: 'pushed' }): number {
457
+ let abandoned = 0;
458
+ for (const refusal of outcome.refusals) {
459
+ if (refusal.reason === 'rejected') {
460
+ // Uncertified means this node retained no proof for the revision: the receiver's
461
+ // default policy refuses it, and no retry changes that until the block is rewritten.
462
+ log('push:rejected block=%s peer=%s rev=%d certified=%s%s', entry.blockId, refusal.peerId, entry.rev,
463
+ outcome.certified, outcome.certified ? '' : ' (no retained proof — cannot place)');
464
+ } else {
465
+ log('push:unreachable block=%s peer=%s err=%s', entry.blockId, refusal.peerId, refusal.error);
466
+ }
467
+ if (this.countFailure(entry.blockId, refusal.peerId)) abandoned++;
468
+ }
469
+ return abandoned;
470
+ }
471
+
472
+ /** Count one unconfirmed round for a peer; abandon it at the bound. @returns whether it was abandoned now. */
473
+ private countFailure(blockId: BlockId, peerId: string): boolean {
474
+ let perPeer = this.failures.get(blockId);
475
+ if (!perPeer) {
476
+ perPeer = new Map();
477
+ this.failures.set(blockId, perPeer);
478
+ }
479
+ const count = (perPeer.get(peerId) ?? 0) + 1;
480
+ if (count < this.config.maxAttempts) {
481
+ perPeer.set(peerId, count);
482
+ return false;
483
+ }
484
+ perPeer.delete(peerId);
485
+ let peers = this.abandoned.get(blockId);
486
+ if (!peers) {
487
+ peers = new Set();
488
+ this.abandoned.set(blockId, peers);
489
+ }
490
+ peers.add(peerId);
491
+ log('peer:abandoned block=%s peer=%s after=%d rounds (until it reconnects)', blockId, peerId, count);
492
+ return true;
493
+ }
494
+
495
+ /**
496
+ * Who the entry still owes. Named members are taken as recorded. An unknown set re-resolves the
497
+ * cohort NOW; a cohort of this node alone is `solo` — not a failure, not an attempt.
498
+ */
499
+ private async owedPeers(entry: UnderReplicatedEntry): Promise<Owed> {
500
+ const selfId = this.deps.libp2p.peerId.toString();
501
+ if (entry.missingPeerIds.length > 0) {
502
+ return { kind: 'peers', peerIds: entry.missingPeerIds.filter(peerId => peerId !== selfId), named: true };
503
+ }
504
+ let cluster: Awaited<ReturnType<IKeyNetwork['findCluster']>>;
505
+ try {
506
+ cluster = await this.deps.keyNetwork.findCluster(routingKeyForBlock(entry.blockId));
507
+ } catch (err) {
508
+ log('cohort:unresolved block=%s err=%s', entry.blockId, err instanceof Error ? err.message : String(err));
509
+ return { kind: 'unresolved' };
510
+ }
511
+ const members = Object.keys(cluster).filter(peerId => peerId !== selfId);
512
+ return members.length === 0 ? { kind: 'solo' } : { kind: 'peers', peerIds: members, named: false };
513
+ }
514
+
515
+ /**
516
+ * Connected peers identified as serving this network's block-transfer protocol — the only peers a
517
+ * push can land on. Identify fills the peer store's protocol list, so a peer that just connected
518
+ * is not yet pushable; the `peer:identify` trigger brings it in. With no prefix configured every
519
+ * connected peer counts.
520
+ */
521
+ private async pushablePeers(): Promise<Set<string>> {
522
+ const seen = new Set<string>();
523
+ const pushable = new Set<string>();
524
+ for (const connection of this.deps.libp2p.getConnections()) {
525
+ const peerId = connection.remotePeer;
526
+ const peerIdStr = peerId.toString();
527
+ if (seen.has(peerIdStr)) continue;
528
+ seen.add(peerIdStr);
529
+ if (this.deps.protocolPrefix === undefined || await this.servesBlockTransfer(peerId)) {
530
+ pushable.add(peerIdStr);
531
+ }
532
+ }
533
+ return pushable;
534
+ }
535
+
536
+ private async servesBlockTransfer(peerId: PeerId): Promise<boolean> {
537
+ try {
538
+ const peer = await this.deps.libp2p.peerStore.get(peerId);
539
+ return peer.protocols.includes(this.blockTransferProtocol);
540
+ } catch {
541
+ // Not in the peer store (not yet identified) or a store fault: not pushable this pass.
542
+ return false;
543
+ }
544
+ }
545
+
546
+ // ── In-memory state upkeep ───────────────────────────────────────
547
+
548
+ private forgetPeer(peerId: string): void {
549
+ for (const peers of this.failures.values()) peers.delete(peerId);
550
+ for (const [blockId, peers] of this.abandoned) {
551
+ if (peers.delete(peerId) && peers.size === 0) this.abandoned.delete(blockId);
552
+ }
553
+ }
554
+
555
+ /** An abandoned peer that has gone is forgotten: when it returns it is retried from scratch. */
556
+ private forgetDisconnected(pushable: Set<string>): void {
557
+ for (const [blockId, peers] of this.abandoned) {
558
+ for (const peerId of peers) {
559
+ if (!pushable.has(peerId)) peers.delete(peerId);
560
+ }
561
+ if (peers.size === 0) this.abandoned.delete(blockId);
562
+ }
563
+ }
564
+
565
+ private forgetBlock(blockId: BlockId): void {
566
+ this.failures.delete(blockId);
567
+ this.abandoned.delete(blockId);
568
+ }
569
+
570
+ /** Drop state for blocks the ledger no longer lists (settled by a `full` commit, evicted, dropped). */
571
+ private pruneState(entries: UnderReplicatedEntry[]): void {
572
+ const listed = new Set(entries.map(entry => entry.blockId));
573
+ for (const blockId of [...this.failures.keys(), ...this.abandoned.keys()]) {
574
+ if (!listed.has(blockId)) this.forgetBlock(blockId);
575
+ }
576
+ }
577
+ }
578
+
579
+ /** Never hold the process open for a drain timer. */
580
+ function unref(timer: ReturnType<typeof setTimeout>): void {
581
+ (timer as unknown as { unref?: () => void }).unref?.();
582
+ }