From d43b86b55dc6a10eb9a9338ecd79d6b6229e79d6 Mon Sep 17 00:00:00 2001 From: vilenarios Date: Mon, 29 Jun 2026 23:11:12 +0000 Subject: [PATCH] fix(chunk-post): key sorted-peer memo by set, not length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSortedChunkPostPeers memoized its weighted peer ordering with a normalizer of `eligiblePeers.length`, so two *different* eligible-peer sets of equal size shared one cached ordering for the full CHUNK_POST_SORTED_PEERS_CACHE_DURATION_MS window (10s default). That quietly narrowed chunk-POST seeding diversity: a churning-but-same-size peer set kept reusing the first set's ordering. Key by the set of peer ids instead (order-independent sort+join). Still cheap — a sort+join over the bounded postChunk peer set, far less than the weighted selection it guards — and the 10s maxAge still absorbs gradual weight drift. Extracted as the pure, exported `chunkPostPeersCacheKey` so the keying is unit-tested directly (distinct same-length sets ⇒ distinct keys; order- independent; no input mutation; empty set). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XQPK4TXcVoXoyFp6sNW2Lr --- src/arweave/composite-client.test.ts | 36 +++++++++++++++++++++++++++- src/arweave/composite-client.ts | 26 ++++++++++++++++---- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/arweave/composite-client.test.ts b/src/arweave/composite-client.test.ts index 91fe02824..a303aa55f 100644 --- a/src/arweave/composite-client.test.ts +++ b/src/arweave/composite-client.test.ts @@ -8,7 +8,10 @@ import { strict as assert } from 'node:assert'; import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import { default as Arweave } from 'arweave'; -import { ArweaveCompositeClient } from './composite-client.js'; +import { + ArweaveCompositeClient, + chunkPostPeersCacheKey, +} from './composite-client.js'; import { UniformFailureSimulator } from '../lib/chaos.js'; import { ArweavePeerManager } from '../peers/arweave-peer-manager.js'; import log from '../log.js'; @@ -409,4 +412,35 @@ describe('ArweaveCompositeClient', () => { (client as any).peerGetChunk = originalPeerGetChunk; }); }); + + describe('chunkPostPeersCacheKey', () => { + it('distinguishes different peer sets of equal length', () => { + // The bug this fixes: a length-only key collided these onto one cached + // ordering for the full cache window, narrowing seeding diversity. + const a = ['http://a:1984', 'http://b:1984', 'http://c:1984']; + const b = ['http://x:1984', 'http://y:1984', 'http://z:1984']; + assert.equal(a.length, b.length); + assert.notEqual(chunkPostPeersCacheKey(a), chunkPostPeersCacheKey(b)); + }); + + it('is order-independent for the same set (set identity)', () => { + const ordered = ['http://a:1984', 'http://b:1984', 'http://c:1984']; + const shuffled = ['http://c:1984', 'http://a:1984', 'http://b:1984']; + assert.equal( + chunkPostPeersCacheKey(ordered), + chunkPostPeersCacheKey(shuffled), + ); + }); + + it('does not mutate the input array', () => { + const peers = ['http://c:1984', 'http://a:1984', 'http://b:1984']; + const snapshot = [...peers]; + chunkPostPeersCacheKey(peers); + assert.deepEqual(peers, snapshot); + }); + + it('handles the empty set', () => { + assert.equal(chunkPostPeersCacheKey([]), ''); + }); + }); }); diff --git a/src/arweave/composite-client.ts b/src/arweave/composite-client.ts index 9ca2295e9..7770ef5cf 100644 --- a/src/arweave/composite-client.ts +++ b/src/arweave/composite-client.ts @@ -156,6 +156,20 @@ interface PeerChunkQueue { totalSuccesses: number; } +/** + * Stable memo key for the chunk-POST peer-sort cache. Identifies the *set* of + * eligible peers independent of order, so two different peer sets of equal + * length no longer collide on a shared cached ordering (the prior length-only + * key did, narrowing seeding diversity within the cache window). + * + * Cheap by design: one sort + join over the bounded postChunk peer set, far + * less work than the weighted selection the memo guards. Does not mutate the + * caller's array (`slice` before `sort`). + */ +export function chunkPostPeersCacheKey(peers: readonly string[]): string { + return peers.slice().sort().join(','); +} + export class ArweaveCompositeClient implements ChainSource, @@ -311,11 +325,13 @@ export class ArweaveCompositeClient }, { maxAge: config.CHUNK_POST_SORTED_PEERS_CACHE_DURATION_MS, - // Use array length as cache key for O(1) performance. This means different - // peer lists of the same length will share cached results, which is acceptable - // because: 1) peer weights change gradually, 2) the cache duration is short (10s), - // and 3) this avoids expensive operations on every chunk POST request. - normalizer: (args) => args[0].length.toString(), + // Key by the *set* of eligible peers (order-independent), not its length. + // The prior length-only key collided distinct peer sets of equal size onto + // one cached ordering for the full 10s window, quietly narrowing seeding + // diversity. This key is still cheap — a sort+join over the bounded + // postChunk peer set, far less than the weighted selection it guards — and + // the 10s maxAge still absorbs gradual weight drift. + normalizer: (args) => chunkPostPeersCacheKey(args[0]), }, );