Skip to content

Commit f29516b

Browse files
authored
fix(supply): harden shard budget guard (#7596)
* fix(supply): harden shard budget regression guard * fix(review): release shard bodies between retries
1 parent e02f0fa commit f29516b

2 files changed

Lines changed: 79 additions & 23 deletions

File tree

scripts/seed-supply-vulnerability.mjs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export const MIN_CHOKEPOINT_COVERAGE = 7;
6060
export const FLOW_STATUS_CHOKEPOINT_IDS = Object.freeze(
6161
new Set(CHOKEPOINT_MAP.map((entry) => entry.canonicalId)),
6262
);
63-
const MAX_SHARD_PIPELINE_BATCHES = 15;
63+
export const MAX_SHARD_PIPELINE_BATCHES = 15;
6464
const PIPELINE_TIMEOUT_MS = 15_000;
6565
const AUXILIARY_WRITE_TIMEOUT_MS = 5_000;
6666
// Bulk shard batches carry up to MAX_SHARD_PIPELINE_BYTES; the small
@@ -700,7 +700,11 @@ export async function readRedisSnapshots(keys, {
700700
return result;
701701
}
702702

703-
const MAX_SHARD_PIPELINE_BYTES = 4.5 * 1024 * 1024;
703+
export const MAX_SHARD_PIPELINE_BYTES = 4.5 * 1024 * 1024;
704+
705+
// This local packing target keeps shard writes within the 30-second write
706+
// timeout and the process heap budget. Upstash's per-command limit does not
707+
// bound the size of the multi-command pipeline body.
704708

705709
export function buildShardPipelineBatches(payload) {
706710
const commands = buildShardEntries(payload).map(({ key, value }) => {
@@ -746,16 +750,19 @@ export async function publishShardCohort(payload, {
746750
if (batches.length > MAX_SHARD_PIPELINE_BATCHES) {
747751
throw new Error(`Supply vulnerability needs ${batches.length} shard batches, above the ${MAX_SHARD_PIPELINE_BATCHES}-batch runtime budget`);
748752
}
753+
const requestBytes = [];
749754
const normalizedConcurrency = Math.max(1, Math.trunc(concurrency));
750755
const writeBatchOnce = async (commands) => {
756+
const body = JSON.stringify(commands);
757+
requestBytes.push(Buffer.byteLength(body, 'utf8'));
751758
const response = await fetchImpl(`${url}/pipeline`, {
752759
method: 'POST',
753760
headers: {
754761
Authorization: `Bearer ${token}`,
755762
'Content-Type': 'application/json',
756763
'User-Agent': CHROME_UA,
757764
},
758-
body: JSON.stringify(commands),
765+
body,
759766
signal: AbortSignal.timeout(SHARD_WRITE_TIMEOUT_MS),
760767
});
761768
if (!response.ok) {
@@ -780,7 +787,12 @@ export async function publishShardCohort(payload, {
780787
for (let start = 0; start < batches.length; start += normalizedConcurrency) {
781788
await Promise.all(batches.slice(start, start + normalizedConcurrency).map(writeBatch));
782789
}
783-
return { batches: batches.length, shards: batches.reduce((count, batch) => count + batch.length, 0) };
790+
return {
791+
batches: batches.length,
792+
shards: batches.reduce((count, batch) => count + batch.length, 0),
793+
totalRequestBytes: requestBytes.reduce((total, bytes) => total + bytes, 0),
794+
maxRequestBytes: Math.max(0, ...requestBytes),
795+
};
784796
}
785797

786798
export async function fetchSourceSnapshots() {
@@ -883,7 +895,18 @@ if (isMain) {
883895
expectedHs4: COMMODITY_REGISTRY.commodities.flatMap((mapping) => mapping.hs4 || []),
884896
expectedHs2: COMMODITY_REGISTRY.commodities.map((mapping) => mapping.transitHs2).filter(Boolean),
885897
})) throw new Error('supply vulnerability payload failed coverage or cross-index validation');
886-
await publishShardCohort(payload);
898+
const publication = await publishShardCohort(payload);
899+
console.log(
900+
`[SupplyVulnerability] wrote ${publication.shards} shards in `
901+
+ `${publication.batches}/${MAX_SHARD_PIPELINE_BATCHES} batches; `
902+
+ `largest request ${publication.maxRequestBytes} bytes`,
903+
);
904+
if (publication.batches >= MAX_SHARD_PIPELINE_BATCHES - 1) {
905+
console.warn(
906+
`[SupplyVulnerability] shard batch headroom is low: `
907+
+ `${publication.batches}/${MAX_SHARD_PIPELINE_BATCHES} batches`,
908+
);
909+
}
887910
},
888911
publishTransform: projectCountryIndex,
889912
extraKeys: [{

tests/supply-vulnerability-seeder.test.mjs

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,28 @@ import {
2525
publishVulnerabilityCohort,
2626
readRedisSnapshots,
2727
validatePayload,
28+
MAX_SHARD_PIPELINE_BATCHES,
29+
MAX_SHARD_PIPELINE_BYTES,
2830
} from '../scripts/seed-supply-vulnerability.mjs';
31+
import { loadSharedConfig } from '../scripts/_seed-utils.mjs';
2932
import {
33+
COMMODITY_REGISTRY,
3034
MIN_COUNTRY_COVERAGE,
3135
MIN_SCORED_COMMODITIES_PER_RANKABLE_COUNTRY,
3236
buildVulnerabilityCoverageRequirements,
3337
} from '../scripts/shared/supply-vulnerability-coverage.mjs';
3438
import { computeCountryLevelExposure } from '../scripts/seed-hs2-chokepoint-exposure.mjs';
39+
import { CHOKEPOINT_REGISTRY } from '../src/config/chokepoint-registry.ts';
3540

3641
const evaluatedAt = '2026-08-30T00:00:00.000Z';
42+
const COUNTRY_PORT_CLUSTERS = loadSharedConfig('country-port-clusters.json');
43+
const countryCount = Object.keys(COUNTRY_PORT_CLUSTERS)
44+
.filter((iso2) => /^[A-Z]{2}$/.test(iso2)).length;
45+
const commodityCount = COMMODITY_REGISTRY.commodities.length;
46+
const chokepointCount = CHOKEPOINT_REGISTRY.length;
47+
const dependencyRatios = [0.76, 0.76, 0.72, 0.82, 0.55, 0.69, 0.44, 0.56, 0.34];
48+
const PREVIOUS_MAX_SHARD_PIPELINE_BATCHES = 15;
49+
const PREVIOUS_SHARD_PIPELINE_BYTES = 4 * 1024 * 1024;
3750

3851
function snapshot(data, fetchedAt = '2026-08-29T00:00:00.000Z') {
3952
return { data, fetchedAt };
@@ -402,7 +415,9 @@ describe('supply-vulnerability publication', () => {
402415
assert.equal(dependency.score, wheat.score);
403416
assert.equal(dependency.transitShare, wheat.components.transitExposure.chokepoints[0].transitShare);
404417
assert.ok(buildShardEntries(payload).every(({ value }) => Buffer.byteLength(JSON.stringify(value)) < 5 * 1024 * 1024));
405-
assert.ok(buildShardPipelineBatches(payload).every((batch) => Buffer.byteLength(JSON.stringify(batch)) < 4 * 1024 * 1024));
418+
assert.ok(buildShardPipelineBatches(payload).every((batch) => (
419+
Buffer.byteLength(JSON.stringify(batch)) <= MAX_SHARD_PIPELINE_BYTES
420+
)));
406421
assert.equal(nextShardSlot(0), 1);
407422
assert.equal(nextShardSlot(1), 0);
408423
});
@@ -485,12 +500,12 @@ describe('supply-vulnerability publication', () => {
485500
});
486501
const template = base.countries.AE.vulnerabilities[0];
487502
const countries = {};
488-
for (let countryIndex = 0; countryIndex < 197; countryIndex += 1) {
503+
for (let countryIndex = 0; countryIndex < countryCount; countryIndex += 1) {
489504
const iso2 = `X${String(countryIndex).padStart(3, '0')}`;
490505
countries[iso2] = {
491506
iso2,
492507
name: `Country ${countryIndex}`,
493-
vulnerabilities: Array.from({ length: 23 }, (_, commodityIndex) => ({
508+
vulnerabilities: Array.from({ length: commodityCount }, (_, commodityIndex) => ({
494509
...structuredClone(template),
495510
countryIso2: iso2,
496511
countryName: `Country ${countryIndex}`,
@@ -500,7 +515,7 @@ describe('supply-vulnerability publication', () => {
500515
...structuredClone(template.components),
501516
transitExposure: {
502517
...structuredClone(template.components.transitExposure),
503-
chokepoints: Array.from({ length: 13 }, (_, routeIndex) => ({
518+
chokepoints: Array.from({ length: chokepointCount }, (_, routeIndex) => ({
504519
...structuredClone(template.components.transitExposure.chokepoints[0]),
505520
id: `route-${routeIndex}`,
506521
name: `Representative route ${routeIndex}`,
@@ -511,7 +526,9 @@ describe('supply-vulnerability publication', () => {
511526
};
512527
}
513528
const records = Object.values(countries).flatMap((country) => country.vulnerabilities);
514-
const dependencyCounts = [3441, 3441, 3248, 3717, 2505, 3115, 1984, 2518, 1535];
529+
const dependencyCounts = Array.from({ length: chokepointCount }, (_, routeIndex) => (
530+
Math.max(1, Math.round(records.length * dependencyRatios[routeIndex % dependencyRatios.length]))
531+
));
515532
const chokepoints = Object.fromEntries(dependencyCounts.map((count, routeIndex) => {
516533
const id = `route-${routeIndex}`;
517534
return [id, {
@@ -537,23 +554,31 @@ describe('supply-vulnerability publication', () => {
537554
}];
538555
}));
539556
const payload = { ...base, countries, chokepoints };
540-
const legacyBytes = Buffer.byteLength(JSON.stringify({
541-
generatedAt: payload.generatedAt,
542-
methodologyVersion: payload.methodologyVersion,
543-
countries,
544-
}));
545-
assert.ok(legacyBytes > 5 * 1024 * 1024, `expected legacy projection above 5MB, got ${legacyBytes}`);
557+
const shardEntries = buildShardEntries(payload);
558+
const totalCommandBytes = shardEntries.reduce((total, { key, value }) => (
559+
total + Buffer.byteLength(JSON.stringify([
560+
'SET',
561+
key,
562+
JSON.stringify(value),
563+
'EX',
564+
SHARD_TTL_SECONDS,
565+
]), 'utf8')
566+
), 0);
567+
assert.ok(
568+
totalCommandBytes > PREVIOUS_MAX_SHARD_PIPELINE_BATCHES * PREVIOUS_SHARD_PIPELINE_BYTES,
569+
`fixture must exceed the pre-fix 15-request capacity, got ${totalCommandBytes}`,
570+
);
546571
assert.ok(Buffer.byteLength(JSON.stringify(projectCountryIndex(payload))) < 5 * 1024 * 1024);
547-
assert.ok(buildShardEntries(payload).every(({ value }) => Buffer.byteLength(JSON.stringify(value)) < 5 * 1024 * 1024));
572+
assert.ok(shardEntries.every(({ value }) => Buffer.byteLength(JSON.stringify(value)) < 5 * 1024 * 1024));
548573

549-
const batches = buildShardPipelineBatches(payload);
550-
assert.ok(batches.length <= 15, `all shard batches must fit one write wave, got ${batches.length}`);
551574
let active = 0;
552575
let peak = 0;
576+
const requestBytes = [];
553577
const result = await publishShardCohort(payload, {
554578
credentials: { url: 'https://redis.example', token: 'test-token' },
555579
retryDelayMs: 0,
556580
fetchImpl: async (_url, request) => {
581+
requestBytes.push(Buffer.byteLength(request.body, 'utf8'));
557582
active += 1;
558583
peak = Math.max(peak, active);
559584
await new Promise((resolve) => setImmediate(resolve));
@@ -565,9 +590,12 @@ describe('supply-vulnerability publication', () => {
565590
};
566591
},
567592
});
568-
assert.equal(result.batches, batches.length);
569-
assert.equal(result.shards, buildShardEntries(payload).length);
570-
assert.equal(peak, batches.length, 'all shard batches must fit in one write wave');
593+
assert.ok(result.batches <= MAX_SHARD_PIPELINE_BATCHES);
594+
assert.equal(result.shards, shardEntries.length);
595+
assert.equal(result.totalRequestBytes, requestBytes.reduce((sum, bytes) => sum + bytes, 0));
596+
assert.equal(result.maxRequestBytes, Math.max(...requestBytes));
597+
assert.ok(result.maxRequestBytes <= MAX_SHARD_PIPELINE_BYTES);
598+
assert.equal(peak, result.batches, 'all shard batches must fit in one write wave');
571599
});
572600

573601
it('bounds source-read concurrency across Redis pipeline batches', async () => {
@@ -661,12 +689,14 @@ describe('supply-vulnerability publication', () => {
661689
countryNames: { AE: 'United Arab Emirates' },
662690
});
663691
let calls = 0;
664-
await publishShardCohort(payload, {
692+
const requestBytes = [];
693+
const result = await publishShardCohort(payload, {
665694
credentials: { url: 'https://redis.example', token: 'test-token' },
666695
concurrency: 1,
667696
retryDelayMs: 0,
668697
fetchImpl: async (_url, request) => {
669698
calls += 1;
699+
requestBytes.push(Buffer.byteLength(request.body, 'utf8'));
670700
if (calls === 1) return { ok: false, status: 503, headers: new Headers() };
671701
return {
672702
ok: true,
@@ -675,6 +705,9 @@ describe('supply-vulnerability publication', () => {
675705
},
676706
});
677707
assert.equal(calls, 2);
708+
assert.equal(result.totalRequestBytes, requestBytes.reduce((sum, bytes) => sum + bytes, 0));
709+
assert.equal(result.maxRequestBytes, Math.max(...requestBytes));
710+
assert.equal(requestBytes[0], requestBytes[1]);
678711
});
679712

680713
it('atomically switches the shared cohort and activation marker and propagates failure', async () => {

0 commit comments

Comments
 (0)