mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-31 18:30:39 +02:00
feat(test): duration-aware LPT shard packing for the free suite
Hash sharding balances file COUNTS (1.15x spread) but not cost — the Playwright-launching files landed 4/3/4/1/2/1 across 6 shards, giving a measured 28s–97s shard spread and ~40s of idle tail on every run. Full-suite mode now packs by recorded per-file durations (longest-processing-time-first) when the committed seed scripts/free-test-durations.json exists. - ONE store, no overlay: the seed is refreshed occasionally via the new --record-durations mode (each file timed in its own child — exact, and immune to bun's stream buffering, where silent passers print no header to timestamp); GSTACK_FREE_TEST_DURATIONS overrides the path for experiments; CI never records - seed is a hint: missing → silent hash-shard fallback; corrupt (bad merge) → one warning + fallback; unknown files → 75th-percentile pessimism so a surprise long-runner can't recreate the tail - packed shards get duration-aware walls (max(base, predicted x 3)) — LPT decouples count from cost BY DESIGN, so the 5s/file heuristic would undersize a shard holding few expensive files - one log line per shard (files + predicted seconds) so packing regressions are diagnosable from any run log - the --shard CI-matrix path is untouched: stable hash indices are its contract - successor note in-code: bun >=1.3.14 ships native --timings/--shard LPT — swap this packer when the repo unpins 1.3.13 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1de75acc27
commit
b4cc808ba1
@@ -23,6 +23,12 @@ import {
|
||||
TREE_MUTATING,
|
||||
WORKER_HOSTILE,
|
||||
} from '../scripts/test-free-shards';
|
||||
import {
|
||||
loadFreeTestDurations,
|
||||
packShardsByDuration,
|
||||
wallTimeoutForPackedShard,
|
||||
DEFAULT_WALL_TIMEOUT_MS as WALL_BASE_MS,
|
||||
} from '../scripts/test-free-shards';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
@@ -566,3 +572,82 @@ describe('test-free-shards: wall-timeout scaling', () => {
|
||||
expect(wallTimeoutForShard(10, 10 * 60_000)).toBe(10 * 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('test-free-shards: duration-aware packing (full-suite LPT)', () => {
|
||||
const files = ['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts', 'test/d.test.ts'];
|
||||
|
||||
test('LPT balances by cost, not count', () => {
|
||||
const durations = {
|
||||
'test/a.test.ts': 90_000, // one giant file
|
||||
'test/b.test.ts': 30_000,
|
||||
'test/c.test.ts': 30_000,
|
||||
'test/d.test.ts': 30_000,
|
||||
};
|
||||
const { shards, predictedMs } = packShardsByDuration(files, 2, durations);
|
||||
// The giant file gets its own shard; the three smalls share the other.
|
||||
expect(shards.map((s) => s.length).sort()).toEqual([1, 3]);
|
||||
expect(Math.max(...predictedMs)).toBe(90_000);
|
||||
});
|
||||
|
||||
test('deterministic for identical inputs', () => {
|
||||
const durations = { 'test/a.test.ts': 5, 'test/b.test.ts': 5, 'test/c.test.ts': 5, 'test/d.test.ts': 5 };
|
||||
const one = packShardsByDuration(files, 3, durations);
|
||||
const two = packShardsByDuration([...files].reverse(), 3, durations);
|
||||
expect(one.shards).toEqual(two.shards);
|
||||
});
|
||||
|
||||
test('unknown files get 75th-percentile pessimism (placed early, never the tail)', () => {
|
||||
const durations = {
|
||||
'test/a.test.ts': 1_000,
|
||||
'test/b.test.ts': 2_000,
|
||||
'test/c.test.ts': 100_000,
|
||||
// test/d.test.ts unrecorded → p75 of known = 100_000 (pessimistic)
|
||||
};
|
||||
const { shards } = packShardsByDuration(files, 2, durations);
|
||||
// The unknown must NOT be packed as if free: it lands opposite the
|
||||
// 100s file, not stacked onto it.
|
||||
const shardOfC = shards.findIndex((s) => s.includes('test/c.test.ts'));
|
||||
const shardOfD = shards.findIndex((s) => s.includes('test/d.test.ts'));
|
||||
expect(shardOfC).not.toBe(shardOfD);
|
||||
});
|
||||
|
||||
test('every file lands in exactly one shard', () => {
|
||||
const { shards } = packShardsByDuration(files, 3, {});
|
||||
expect(shards.flat().sort()).toEqual([...files].sort());
|
||||
});
|
||||
|
||||
test('invalid shard count throws', () => {
|
||||
expect(() => packShardsByDuration(files, 0, {})).toThrow();
|
||||
});
|
||||
|
||||
test('corrupt seed falls back to null (hash sharding), never throws', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'durations-seed-'));
|
||||
const seedPath = path.join(dir, 'seed.json');
|
||||
fs.writeFileSync(seedPath, '{ definitely not json');
|
||||
const prev = process.env.GSTACK_FREE_TEST_DURATIONS;
|
||||
process.env.GSTACK_FREE_TEST_DURATIONS = seedPath;
|
||||
try {
|
||||
expect(loadFreeTestDurations()).toBeNull();
|
||||
// Missing file: silent null (fresh checkouts are normal).
|
||||
process.env.GSTACK_FREE_TEST_DURATIONS = path.join(dir, 'missing.json');
|
||||
expect(loadFreeTestDurations()).toBeNull();
|
||||
// Valid seed round-trips, non-numeric entries dropped.
|
||||
fs.writeFileSync(seedPath, JSON.stringify({ version: 1, durations: { 'test/a.test.ts': 42, bad: 'nope' } }));
|
||||
process.env.GSTACK_FREE_TEST_DURATIONS = seedPath;
|
||||
expect(loadFreeTestDurations()).toEqual({ 'test/a.test.ts': 42 });
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.GSTACK_FREE_TEST_DURATIONS;
|
||||
else process.env.GSTACK_FREE_TEST_DURATIONS = prev;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('packed shards get duration-aware walls (count heuristic is wrong under LPT)', () => {
|
||||
// A packed shard predicted at 120s must get a 360s wall even though its
|
||||
// file COUNT would produce only the 6-minute base under the old formula.
|
||||
expect(wallTimeoutForPackedShard(120_000)).toBe(Math.max(WALL_BASE_MS, 360_000));
|
||||
// Tiny prediction: base still floors it.
|
||||
expect(wallTimeoutForPackedShard(1_000)).toBe(WALL_BASE_MS);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user