diff --git a/src/arweave/composite-client.test.ts b/src/arweave/composite-client.test.ts index 91fe0282..a303aa55 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 9ca2295e..7770ef5c 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]), }, );