@serfab/cadre-core 1.18.0 → 1.19.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.
@@ -1,6 +1,6 @@
1
1
  import debug from 'debug';
2
2
  import type { IdentifyResult, Libp2p, PeerId } from '@libp2p/interface';
3
- import type { ActionId, ActionRev, IPeerNetwork } from '@optimystic/db-core';
3
+ import { jsonEncodedBytes, MAX_BLOCK_BYTES, type ActionId, type ActionRev, type BlockId, type IBlock, type IPeerNetwork } from '@optimystic/db-core';
4
4
  import { BlockTransferClient, buildBlockTransferProtocol, type BlockCommitProof, type IRawStorage } from '@optimystic/db-p2p';
5
5
  import { peerJoinPushBudget } from './link-budget.js';
6
6
  import type { Libp2pNodeWithRepo } from './types.js';
@@ -182,15 +182,6 @@ import type { Libp2pNodeWithRepo } from './types.js';
182
182
  // clear the pending timer in that handler.
183
183
 
184
184
  const log = debug('sereus:cadre:peer-join-backfill');
185
- const blockEncoder = new TextEncoder();
186
-
187
- /**
188
- * The block-transfer protocol's hard cap on one length-prefixed message. Mirrors
189
- * `MAX_BLOCK_MESSAGE_BYTES` in `@optimystic/db-p2p`'s `protocol-limits.ts` (8 MiB), which
190
- * that package does not re-export from its index — keep the two in sync if upstream ever
191
- * changes it or starts exporting it.
192
- */
193
- export const MAX_BLOCK_MESSAGE_BYTES = 8 * 1024 * 1024;
194
185
 
195
186
  /**
196
187
  * Whether an identified peer's protocol list names this network's own block-transfer
@@ -222,7 +213,11 @@ export interface PeerJoinBackfillConfig {
222
213
  debounceMs?: number;
223
214
  /** Ceiling on blocks copied in one catch-up. Default 10_000. Reaching it is LOGGED, never silent. */
224
215
  maxBlocks?: number;
225
- /** Soft byte budget per push message. Default 1 MiB. Protocol hard cap is {@link MAX_BLOCK_MESSAGE_BYTES} (8 MiB). */
216
+ /**
217
+ * Soft byte budget per push message, as the sum of its blocks' `jsonEncodedBytes` (the form they
218
+ * travel in). Default 1 MiB. A single block over it still ships alone; one over `MAX_BLOCK_BYTES`
219
+ * never ships (see {@link PeerJoinBackfillResult.oversized}).
220
+ */
226
221
  maxChunkBytes?: number;
227
222
  /** Max blocks per push message. Default 64. */
228
223
  maxChunkBlocks?: number;
@@ -328,7 +323,7 @@ export interface PeerJoinBackfillResult {
328
323
  unmaterialized: number;
329
324
  /** Not attempted because `maxBlocks` was reached. */
330
325
  capped: number;
331
- /** Skipped: a single block whose wire size alone exceeds {@link MAX_BLOCK_MESSAGE_BYTES}. */
326
+ /** Skipped: a block whose `jsonEncodedBytes` exceeds db-core's per-block limit, `MAX_BLOCK_BYTES`. */
332
327
  oversized: string[];
333
328
  /** True when the `authorizePeer` gate refused (or threw) — nothing was pushed, peer not memoized. */
334
329
  denied: boolean;
@@ -384,8 +379,8 @@ function recordReply(memo: PeerMemo, revs: Readonly<Record<string, { rev: number
384
379
 
385
380
  /** One in-flight push message being accumulated. */
386
381
  interface Chunk {
387
- ids: string[];
388
- buffers: Uint8Array[];
382
+ /** Each block as read from this store, keyed by its id — the form `pushBlocks` sends. */
383
+ blocks: Record<BlockId, IBlock>;
389
384
  meta: Record<string, { rev: number; actionId: ActionId }>;
390
385
  /**
391
386
  * The cohort commit proof retained for each block's pushed revision, where one exists.
@@ -400,15 +395,12 @@ interface Chunk {
400
395
  * `latest` in one place.
401
396
  */
402
397
  proofs: Record<string, BlockCommitProof>;
398
+ /** The sum of the blocks' `jsonEncodedBytes`, judged against `maxChunkBytes`. */
403
399
  bytes: number;
404
400
  }
405
401
 
406
- /**
407
- * Base64 wire size of a raw buffer — `pushBlocks` base64-encodes each block into the JSON
408
- * request, so the protocol cap must be judged against the encoded size, not the raw bytes.
409
- */
410
- function base64WireBytes(rawBytes: number): number {
411
- return Math.ceil(rawBytes / 3) * 4;
402
+ function emptyChunk(): Chunk {
403
+ return { blocks: {}, meta: {}, proofs: {}, bytes: 0 };
412
404
  }
413
405
 
414
406
  /**
@@ -828,7 +820,7 @@ export class PeerJoinBackfill {
828
820
 
829
821
  const client = this.createPushClient(peerId);
830
822
  const memo = this.memoFor(key);
831
- let chunk: Chunk = { ids: [], buffers: [], meta: {}, proofs: {}, bytes: 0 };
823
+ let chunk = emptyChunk();
832
824
  let chunkFailed = false;
833
825
  let anyChunkDelivered = false;
834
826
  /**
@@ -840,13 +832,14 @@ export class PeerJoinBackfill {
840
832
  const peerUnreachable = (): boolean => chunkFailed && !anyChunkDelivered;
841
833
 
842
834
  const flush = async (): Promise<void> => {
843
- if (chunk.ids.length === 0) return;
844
- const { ids, buffers, meta, proofs } = chunk;
845
- chunk = { ids: [], buffers: [], meta: {}, proofs: {}, bytes: 0 };
835
+ const { blocks, meta, proofs } = chunk;
836
+ const ids = Object.keys(blocks);
837
+ if (ids.length === 0) return;
838
+ chunk = emptyChunk();
846
839
  try {
847
840
  // ONE certification value rather than two arguments, so a caller cannot pair a proof
848
841
  // for one revision with meta for another — both were written from the same `latest`.
849
- const response = await client.pushBlocks(ids, buffers, 'replication',
842
+ const response = await client.pushBlocks(blocks, 'replication',
850
843
  { blockMeta: meta, blockProofs: proofs }, {
851
844
  dialTimeoutMs: this.config.dialTimeoutMs,
852
845
  responseTimeoutMs: this.config.responseTimeoutMs
@@ -886,18 +879,18 @@ export class PeerJoinBackfill {
886
879
 
887
880
  // Flush before adding when this block would overflow either budget. A single block
888
881
  // larger than maxChunkBytes still ships — alone, in its own chunk.
889
- if (chunk.ids.length > 0
890
- && (chunk.ids.length + 1 > this.config.maxChunkBlocks || chunk.bytes + offer.buffer.length > this.config.maxChunkBytes)) {
882
+ const inChunk = Object.keys(chunk.blocks).length;
883
+ if (inChunk > 0
884
+ && (inChunk + 1 > this.config.maxChunkBlocks || chunk.bytes + offer.bytes > this.config.maxChunkBytes)) {
891
885
  await flush();
892
886
  if (this.stopped || peerUnreachable()) break;
893
887
  }
894
- chunk.ids.push(blockId);
895
- chunk.buffers.push(offer.buffer);
888
+ chunk.blocks[blockId] = offer.block;
896
889
  chunk.meta[blockId] = { rev: offer.latest.rev, actionId: offer.latest.actionId };
897
890
  if (offer.proof) {
898
891
  chunk.proofs[blockId] = offer.proof;
899
892
  }
900
- chunk.bytes += offer.buffer.length;
893
+ chunk.bytes += offer.bytes;
901
894
  result.offered += 1;
902
895
  }
903
896
 
@@ -916,7 +909,7 @@ export class PeerJoinBackfill {
916
909
  /**
917
910
  * Read one block for the push to `key`, or count why it is skipped in `result` and return
918
911
  * nothing: not committed, already held by the peer (memo or its own commit signature), not
919
- * materialized, or too large for one message. The proof is read for EXACTLY the revision the
912
+ * materialized, or over the per-block size limit. The proof is read for EXACTLY the revision the
920
913
  * meta will name, from the same `latest`; absent is normal (nothing retained a proof for that
921
914
  * revision), and the block still ships with its meta for the receiver to decide.
922
915
  */
@@ -925,7 +918,7 @@ export class PeerJoinBackfill {
925
918
  key: string,
926
919
  memo: PeerMemo,
927
920
  result: PeerJoinBackfillResult
928
- ): Promise<{ latest: ActionRev; buffer: Uint8Array; proof: BlockCommitProof | undefined } | undefined> {
921
+ ): Promise<{ latest: ActionRev; block: IBlock; bytes: number; proof: BlockCommitProof | undefined } | undefined> {
929
922
  const { storage } = this.deps;
930
923
  const latest = (await storage.getMetadata(blockId))?.latest;
931
924
  if (!latest) {
@@ -953,19 +946,16 @@ export class PeerJoinBackfill {
953
946
  log('[%s] block=%s has latest rev %d but no materialized content; skipped', this.deps.label, blockId, latest.rev);
954
947
  return undefined;
955
948
  }
956
- const buffer = blockEncoder.encode(JSON.stringify(block));
957
- // NOTE: MAX_BLOCK_MESSAGE_BYTES caps the whole framed request, not one block, and
958
- // this test ignores the request envelope (ids array, blockMeta, JSON punctuation).
959
- // A lone block within a few KiB of the cap therefore still ships and is rejected by
960
- // the receiver's length-prefix decoder — a permanent chunk failure for that peer. No
961
- // block comes close today; if one ever can, subtract a measured envelope allowance
962
- // here rather than raising the cap.
963
- if (base64WireBytes(buffer.length) >= MAX_BLOCK_MESSAGE_BYTES) {
949
+ // A writer refuses a block over MAX_BLOCK_BYTES, and Optimystic sizes the push frame to carry
950
+ // one block at that limit with its meta and proof. A block past the limit grew some other way
951
+ // and may not fit the frame, and a push that does not fit fails on every run.
952
+ const bytes = jsonEncodedBytes(block);
953
+ if (bytes > MAX_BLOCK_BYTES) {
964
954
  result.oversized.push(blockId);
965
- log('[%s] block=%s is %d bytes (%d on the wire) — exceeds the %d-byte protocol cap alone; skipped',
966
- this.deps.label, blockId, buffer.length, base64WireBytes(buffer.length), MAX_BLOCK_MESSAGE_BYTES);
955
+ log('[%s] block=%s encodes to %d bytes, over the %d-byte per-block limit; skipped',
956
+ this.deps.label, blockId, bytes, MAX_BLOCK_BYTES);
967
957
  return undefined;
968
958
  }
969
- return { latest, buffer, proof };
959
+ return { latest, block, bytes, proof };
970
960
  }
971
961
  }
@@ -0,0 +1,19 @@
1
+ import type { PeerUpdate } from '@libp2p/interface';
2
+
3
+ /**
4
+ * Whether a libp2p `self:peer:update` changed the node's own addresses — the only part of
5
+ * the update the self record (`CadrePeer.Multiaddr`) carries.
6
+ *
7
+ * libp2p emits the event for any change to the node's own peer-store entry, and registering
8
+ * or unregistering a protocol handler is one (the registrar merges the node's protocol list).
9
+ * Cadre registers handlers on a running control node (member redemption, strand formation,
10
+ * the seed listener), so reacting to every event would make each of those a consensus write
11
+ * of an unchanged record, racing whatever write the cohort is making to `CadrePeer` at the
12
+ * time. An update with no `previous` is treated as a change: there is nothing to compare.
13
+ */
14
+ export function selfAddressesChanged(update: PeerUpdate): boolean {
15
+ if (update.previous === undefined) return true;
16
+ const before = new Set(update.previous.addresses.map((address) => address.multiaddr.toString()));
17
+ const after = update.peer.addresses.map((address) => address.multiaddr.toString());
18
+ return after.length !== before.size || after.some((address) => !before.has(address));
19
+ }
@@ -577,11 +577,10 @@ export class StrandMembershipReconciler {
577
577
  try {
578
578
  if (!(await this.ensureMembership(db, keyPair))) return;
579
579
  // NOTE: accepted tradeoff — the redemption (`Member` + `ConsumedInvite`) and the binding
580
- // below stay two SEPARATE commits on purpose. One transaction would save no `/cluster`
581
- // requests: Optimystic pends and commits each collection on its own, about 4 requests per
582
- // collection (`blocked/optimystic-multi-collection-commit-costs-four-requests-per-collection`),
583
- // and both commits already span several collections (each table plus its UNIQUE trees),
584
- // so merging saves only one commit's serial latency. It would also let a torn commit
580
+ // below stay two SEPARATE commits on purpose. Merging them would save one commit: its
581
+ // serial latency and, since Optimystic 1.19.0 pends and commits a transaction's collections
582
+ // together, its `/cluster` requests (the two commits cost 6 on a two-member strand in
583
+ // `message-cost-accounting` on 2026-10-25, 20 before). It would also let a torn commit
585
584
  // (Optimystic can report a multi-collection commit partly saved) or the cohort applying
586
585
  // the collections out of order show this machine's `MemberPeer` without its `Member`,
587
586
  // which the revocation enforcer reads as a removed member and hangs up on — fail closed,
@@ -589,8 +588,7 @@ export class StrandMembershipReconciler {
589
588
  // `strand-revocation-enforcer.ts`). And it would add a half-committed shape (`MemberPeer`
590
589
  // saved, the invitation still live) that {@link reportHalfCommittedJoin}'s dropping of the
591
590
  // invitation gets wrong. Revisit when Optimystic offers an all-or-nothing multi-collection
592
- // commit, or when its per-coordinator batching lands and the enforcer can tell a
593
- // not-yet-seated binding from a removed member's.
591
+ // commit, or when the enforcer can tell a not-yet-seated binding from a removed member's.
594
592
  await this.ensureBinding(db, keyPair);
595
593
  } finally {
596
594
  this.noteAttemptOutcome();