7efb04e280
TWO DEFECTS, one lookup. SCALE. The family lookup sent 100 natural keys as a PostgREST IN-list. `read_natural_key` has NO pg_stats row at all -- the table's last autoanalyze (2026-08-26) predates the column ever being populated -- so the planner used a default per-value selectivity, estimated 172,409 rows and chose a sequential scan of 344,818: 8.5s, then 57014. At 50 keys the same shape returned in ~357ms. The cliff is a statistics artifact, not a volume one, which is why the repair does not depend on the estimate improving and is not CH=50. `readNaturalKey` builds `sport|game_date|player_key|stat|side|line[|#event]`, so SPORT AND GAME_DATE ARE COMPONENTS OF THE KEY. Two rows sharing a key necessarily share both, and scoping the lookup to the (sport, game_date) pairs present in the requested keys is LOSSLESS BY CONSTRUCTION. One index-backed range per date, walked with safePaginate; cost is bounded by ONE SLATE however long the chronology gets. Measured: 5,000 keys -> 1 scope, and the plan is `Index Scan using model_snapshots_lineage_family_idx, cost 0.28..1.92`. VALIDITY. A row carrying `read_natural_key` is not history: the key is stamped on every candidate BEFORE the lookup, so a failure leaves it on a row that never became an action. Proven this was not cosmetic -- fed the raw rows the old lookup returned, the resolver produced a REVISION with a NULL read_id (an orphaned chain node) and labelled a brand-new Read LEGACY_UNVERIFIED. `isValidLineageAction` states what a completed action IS: all nine fields, in the query and again in code. ATOMICITY. A failed attempt now leaves NO lineage-specific state. `publication_id`/`published_at` are untouched -- the slate really was published, and erasing a true fact to tidy a false one is the wrong repair. Replayed the exact failed 19:00Z cohort through the real resolver, side-effect free: 119 NEW / 379 CHANGED / 621 UNCHANGED -> ORIGIN 119 / REVISION 379 / RECAPTURE 621, 0 wrong parent, 0 wrong ordinal, 0 null read_id, 0 forks -- byte-identical with all 1,119 failed partial rows present. Clean-head parity 1,024/1,024. Migration 050 is CONCURRENTLY + IF NOT EXISTS, drops nothing, rewrites nothing. Lineage stays OFF. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
343 lines
16 KiB
JavaScript
343 lines
16 KiB
JavaScript
// LINEAGE FAMILY LOOKUP — validity, scale and failure atomicity.
|
|
//
|
|
// The first canary began from an EMPTY graph and passed. The second failed
|
|
// because history existed: the family lookup sent 100 natural keys as a
|
|
// PostgREST IN-list, the planner had no statistics for `read_natural_key`
|
|
// (its last autoanalyze predated the column ever being populated), estimated
|
|
// 172,409 rows and chose a sequential scan of 344,818 -- 8.5s, then 57014.
|
|
//
|
|
// The repair is not a smaller chunk. It is a lookup whose cost is bounded by
|
|
// ONE SLATE, plus a predicate that says what a completed lineage action IS.
|
|
|
|
const ret = require('../../src/services/retentionService');
|
|
const R = require('../../src/services/read/readLineage');
|
|
const { isValidLineageAction, VALID_LINEAGE_ACTION_FIELDS, familyScopesFrom } = ret;
|
|
|
|
const CAND = {
|
|
sport: 'mlb', game_date: '2026-08-28', player_key: 'aaron judge', stat: 'hits',
|
|
side: 'over', line: 0.5, canonical_event_id: 'mlb:gamepk:1',
|
|
captured_at: '2026-08-28T23:00:00.000Z', published: true, p_win: 0.9, grade: 'B+',
|
|
};
|
|
const KEY = R.readNaturalKey(CAND);
|
|
|
|
const validRow = (over = {}) => ({
|
|
id: 1, read_id: '11111111-1111-4111-8111-111111111111', read_natural_key: KEY,
|
|
game_id: 'g', claim_digest: 'OLDDIGEST', revision_ordinal: 0, lineage_action: 'ORIGIN',
|
|
supersedes_id: null, captured_at: '2026-08-28T19:00:00.000Z', lineage_state: 'LIVE',
|
|
lineage_version: 'lin@1', claim_schema_version: 'claim@1',
|
|
digest_algorithm_version: 'sha256-json-sorted@1',
|
|
// Claim fields sit at TOP LEVEL, exactly as the lookup projection returns
|
|
// them: `attachLineage` rebuilds the nested `claim` from these columns, so a
|
|
// fixture that only nests them is testing a shape the database never emits.
|
|
canonical_event_id: 'mlb:gamepk:1', line: 0.5, side: 'over', p_win: 0.5, grade: 'C',
|
|
...over,
|
|
});
|
|
// The EXACT shape the 2026-08-28 failure wrote 1,119 times: a natural key and
|
|
// publication stamps, every lineage action field NULL.
|
|
const partialRow = (over = {}) => ({
|
|
id: 2, read_id: null, read_natural_key: KEY, game_id: 'g', claim_digest: null,
|
|
revision_ordinal: null, lineage_action: null, supersedes_id: null,
|
|
captured_at: '2026-08-28T23:00:00.000Z', lineage_state: null, lineage_version: null,
|
|
claim_schema_version: null, digest_algorithm_version: null,
|
|
publication_id: 'pub-1', published_at: '2026-08-28T19:03:29.579Z',
|
|
canonical_event_id: 'mlb:gamepk:1', line: 0.5, side: 'over',
|
|
...over,
|
|
});
|
|
|
|
describe('STEP 2 — semantic result and persisted action are different layers', () => {
|
|
test('the repository maps NEW/CHANGED/UNCHANGED onto ORIGIN/REVISION/RECAPTURE', () => {
|
|
const noHistory = R.resolveLineage({ candidate: CAND, existing: [], mintReadId: () => 'M' });
|
|
expect(noHistory.change_type).toBe(R.CHANGE_TYPE.INITIAL_PUBLICATION);
|
|
expect(noHistory.action).toBe(R.LINEAGE_ACTION.ORIGIN);
|
|
|
|
const head = validRow();
|
|
const changed = R.resolveLineage({ candidate: CAND, existing: [head], mintReadId: () => 'M' });
|
|
expect(changed.action).toBe(R.LINEAGE_ACTION.REVISION);
|
|
expect(changed.change_type).not.toBe(R.CHANGE_TYPE.NO_MATERIAL_PUBLISHED_CHANGE);
|
|
|
|
const same = R.resolveLineage({
|
|
candidate: CAND,
|
|
existing: [validRow({ claim_digest: R.claimDigest(CAND) })],
|
|
mintReadId: () => 'M',
|
|
});
|
|
expect(same.action).toBe(R.LINEAGE_ACTION.RECAPTURE);
|
|
expect(same.change_type).toBe(R.CHANGE_TYPE.NO_MATERIAL_PUBLISHED_CHANGE);
|
|
});
|
|
|
|
test('NO_MATERIAL_CHANGE and RECAPTURE describe ONE claim, not two', () => {
|
|
// The semantic result and the persisted action are two views of the same
|
|
// row. Counting them as separate populations would double-report a slate.
|
|
const r = R.resolveLineage({
|
|
candidate: CAND,
|
|
existing: [validRow({ claim_digest: R.claimDigest(CAND) })],
|
|
mintReadId: () => 'M',
|
|
});
|
|
expect(r.action).toBe('RECAPTURE');
|
|
expect(r.change_type).toBe('NO_MATERIAL_PUBLISHED_CHANGE');
|
|
// One resolution object. One claim.
|
|
expect(Object.keys(r).filter((k) => k === 'action' || k === 'change_type')).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('STEP 5 — VALID_LINEAGE_ACTION_PREDICATE', () => {
|
|
test('it states what a completed action IS, field by field', () => {
|
|
expect(VALID_LINEAGE_ACTION_FIELDS).toEqual(expect.arrayContaining([
|
|
'read_id', 'read_natural_key', 'lineage_action', 'claim_digest',
|
|
'revision_ordinal', 'lineage_state', 'lineage_version',
|
|
'claim_schema_version', 'digest_algorithm_version',
|
|
]));
|
|
expect(isValidLineageAction(validRow())).toBe(true);
|
|
// Removing ANY required field disqualifies the row — the predicate is not
|
|
// shaped around one known batch.
|
|
for (const f of VALID_LINEAGE_ACTION_FIELDS) {
|
|
expect(isValidLineageAction({ ...validRow(), [f]: null })).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('a read_natural_key alone is NOT lineage history', () => {
|
|
expect(isValidLineageAction({ read_natural_key: KEY })).toBe(false);
|
|
expect(isValidLineageAction(partialRow())).toBe(false);
|
|
// Publication provenance does not make it one either.
|
|
expect(isValidLineageAction({ read_natural_key: KEY, publication_id: 'p', published_at: 'x' })).toBe(false);
|
|
});
|
|
|
|
test('ordinal 0 is a real ordinal, not an absence', () => {
|
|
expect(isValidLineageAction(validRow({ revision_ordinal: 0 }))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('STEP 7/8 — partial rows may not shadow a valid head', () => {
|
|
const withClaim = (r) => ({ ...r, claim: { canonical_event_id: r.canonical_event_id, line: r.line, side: r.side, p_win: r.p_win, grade: r.grade } });
|
|
const resolve = (existing) => R.resolveLineage({
|
|
candidate: CAND, existing: existing.filter(isValidLineageAction).map(withClaim), mintReadId: () => 'MINT',
|
|
});
|
|
|
|
test('A valid ORIGIN + newer partial row, partial FIRST: head, parent and read_id are correct', () => {
|
|
const r = resolve([partialRow(), validRow()]);
|
|
expect(r.action).toBe('REVISION');
|
|
expect(r.read_id).toBe('11111111-1111-4111-8111-111111111111');
|
|
expect(r.supersedes_id).toBe(1);
|
|
expect(r.revision_ordinal).toBe(1);
|
|
expect(r.lineage_state).toBe('LIVE');
|
|
});
|
|
|
|
test('B partial rows ONLY behaves as NO VALID LINEAGE HISTORY', () => {
|
|
const r = resolve([partialRow()]);
|
|
expect(r.action).toBe('ORIGIN');
|
|
expect(r.revision_ordinal).toBe(0);
|
|
expect(r.supersedes_id).toBeNull();
|
|
// LIVE, not LEGACY_UNVERIFIED: no lineage action ever existed here, so
|
|
// claiming unverified history would be a false statement about the past.
|
|
expect(r.lineage_state).toBe('LIVE');
|
|
});
|
|
|
|
test('C valid ORIGIN only, and D no history, are unchanged', () => {
|
|
expect(resolve([validRow()]).action).toBe('REVISION');
|
|
expect(resolve([]).action).toBe('ORIGIN');
|
|
expect(resolve([]).lineage_state).toBe('LIVE');
|
|
});
|
|
|
|
test('THE PRE-REPAIR DEFECTS, reproduced on an unfiltered family', () => {
|
|
// Fed the raw rows the old lookup returned, the resolver produced a
|
|
// REVISION with a NULL read_id (an orphaned chain node) and mislabelled a
|
|
// brand-new Read as LEGACY_UNVERIFIED. Both are reachable by construction.
|
|
const withClaim = (r) => ({ ...r, claim: { canonical_event_id: r.canonical_event_id, line: r.line, side: r.side, p_win: r.p_win, grade: r.grade } });
|
|
const orphan = R.resolveLineage({ candidate: CAND, existing: [withClaim(partialRow()), withClaim(validRow())], mintReadId: () => 'MINT' });
|
|
expect(orphan.action).toBe('REVISION');
|
|
expect(orphan.read_id).toBeNull();
|
|
const mislabelled = R.resolveLineage({ candidate: CAND, existing: [withClaim(partialRow())], mintReadId: () => 'MINT' });
|
|
expect(mislabelled.lineage_state).toBe('LEGACY_UNVERIFIED');
|
|
});
|
|
});
|
|
|
|
describe('STEP 12/13 — the lookup scope is lossless and bounded by one slate', () => {
|
|
test('sport and game_date are COMPONENTS OF THE KEY, so the bound cannot lose a family member', () => {
|
|
// Derived from the key builder itself, not from a parallel assumption.
|
|
const k = R.readNaturalKey(CAND);
|
|
const [sport, gameDate] = k.split('|');
|
|
expect(sport).toBe(CAND.sport);
|
|
expect(gameDate).toBe(CAND.game_date);
|
|
expect(familyScopesFrom([k])).toEqual([{ sport: 'mlb', game_date: '2026-08-28' }]);
|
|
});
|
|
|
|
test('one scope per distinct (sport, date) however many keys are requested', () => {
|
|
const keys = [];
|
|
for (let i = 0; i < 5000; i += 1) {
|
|
keys.push(R.readNaturalKey({ ...CAND, player_key: `p${i}` }));
|
|
}
|
|
// 5,000 keys -> ONE index-backed range, not 50 chunked IN-lists.
|
|
expect(familyScopesFrom(keys)).toHaveLength(1);
|
|
});
|
|
|
|
test('a batch spanning two dates produces exactly two scopes', () => {
|
|
const a = R.readNaturalKey(CAND);
|
|
const b = R.readNaturalKey({ ...CAND, game_date: '2026-08-29' });
|
|
expect(familyScopesFrom([a, b])).toHaveLength(2);
|
|
});
|
|
|
|
test('a malformed key contributes no scope rather than a wildcard one', () => {
|
|
expect(familyScopesFrom(['', 'mlb', null, undefined])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('STEP 15 — failure atomicity', () => {
|
|
test('a lookup that throws leaves NO lineage-specific state behind', async () => {
|
|
const rows = [{ ...CAND, snapshot_id: 's' }];
|
|
const out = await ret.attachLineage(rows, {
|
|
fetchExisting: async () => { throw new Error('57014 canceling statement due to statement timeout'); },
|
|
});
|
|
expect(out.error).toMatch(/57014/);
|
|
// The row still persists -- retention is not lost -- but it carries nothing
|
|
// that could later be mistaken for history.
|
|
expect(rows[0].read_natural_key).toBeNull();
|
|
expect(rows[0].read_id).toBeNull();
|
|
expect(rows[0].lineage_action).toBeNull();
|
|
expect(isValidLineageAction(rows[0])).toBe(false);
|
|
});
|
|
|
|
test('publication provenance is NOT erased by a lineage failure', async () => {
|
|
const rows = [{ ...CAND, snapshot_id: 's', publication_id: 'pub-1', published_at: 'T' }];
|
|
await ret.attachLineage(rows, { fetchExisting: async () => { throw new Error('boom'); } });
|
|
// The slate really was published. Deleting that true fact to tidy up a
|
|
// false one would be the wrong repair.
|
|
expect(rows[0].publication_id).toBe('pub-1');
|
|
expect(rows[0].published_at).toBe('T');
|
|
});
|
|
|
|
test('an unpublished capture is cleared too — it was never an action', async () => {
|
|
const rows = [{ ...CAND, published: false, snapshot_id: 's' }];
|
|
const out = await ret.attachLineage(rows, { fetchExisting: async () => [] });
|
|
expect(out.not_published).toBe(1);
|
|
expect(rows[0].read_natural_key).toBeNull();
|
|
});
|
|
|
|
test('a successful resolution keeps every field', async () => {
|
|
const rows = [{ ...CAND, snapshot_id: 's' }];
|
|
await ret.attachLineage(rows, { fetchExisting: async () => [], mintReadId: () => 'MINT' });
|
|
expect(rows[0].read_natural_key).toBe(KEY);
|
|
expect(rows[0].lineage_action).toBe('ORIGIN');
|
|
expect(isValidLineageAction(rows[0])).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('STEP 21 — deterministic in-batch, digest and fork semantics', () => {
|
|
const run = async (rows, existing) => {
|
|
const out = await ret.attachLineage(rows, {
|
|
fetchExisting: async () => existing, mintReadId: () => 'MINT-UUID',
|
|
});
|
|
return out;
|
|
};
|
|
|
|
test('two claims of one family in a single batch: ORIGIN then RECAPTURE/REVISION', async () => {
|
|
const a = { ...CAND, snapshot_id: 's' };
|
|
const b = { ...CAND, snapshot_id: 's' }; // identical claim
|
|
await run([a, b], []);
|
|
expect(a.lineage_action).toBe('ORIGIN');
|
|
expect(b.lineage_action).toBe('RECAPTURE');
|
|
expect(b.read_id).toBe(a.read_id); // no forked mint
|
|
const c = { ...CAND, snapshot_id: 's' };
|
|
const d = { ...CAND, snapshot_id: 's', p_win: 0.2, grade: 'F' }; // changed claim
|
|
await run([c, d], []);
|
|
expect(c.lineage_action).toBe('ORIGIN');
|
|
expect(d.lineage_action).toBe('REVISION');
|
|
expect(d.revision_ordinal).toBe(1);
|
|
});
|
|
|
|
test('an unchanged digest against an existing valid head is a RECAPTURE at the same ordinal', async () => {
|
|
const row = { ...CAND, snapshot_id: 's' };
|
|
await run([row], [validRow({ claim_digest: R.claimDigest(CAND) })]);
|
|
expect(row.lineage_action).toBe('RECAPTURE');
|
|
expect(row.revision_ordinal).toBe(0);
|
|
expect(row.supersedes_id).toBeNull();
|
|
expect(row.recaptures_id).toBe(1);
|
|
});
|
|
|
|
test('supersedes uniqueness is a DATABASE guard, and the writer names it', () => {
|
|
const src = require('fs').readFileSync(
|
|
require('path').join(__dirname, '../../src/services/retentionService.js'), 'utf8');
|
|
expect(src).toContain('model_snapshots_supersedes_unique');
|
|
});
|
|
});
|
|
|
|
describe('each layer of the defence is pinned INDEPENDENTLY', () => {
|
|
// Removing either layer alone left the suite green: the other caught it. That
|
|
// is defence in depth working and test coverage failing, so each layer now
|
|
// has its own proof.
|
|
|
|
test('LAYER 2 — attachLineage excludes an invalid row a fetcher hands it', async () => {
|
|
const row = { ...CAND, snapshot_id: 's' };
|
|
const out = await ret.attachLineage([row], {
|
|
fetchExisting: async () => [partialRow()], mintReadId: () => 'MINT',
|
|
});
|
|
expect(out.invalid_rows_excluded).toBe(1);
|
|
expect(row.lineage_action).toBe('ORIGIN');
|
|
expect(row.lineage_state).toBe('LIVE');
|
|
});
|
|
|
|
test('LAYER 1 — the default lookup asks the database for valid actions only', async () => {
|
|
const applied = { eq: [], not: [], select: null, table: null };
|
|
const rows = [validRow(), partialRow()];
|
|
const builder = {
|
|
select(c) { applied.select = c; return this; },
|
|
eq(col, val) { applied.eq.push([col, val]); return this; },
|
|
not(col, op, val) { applied.not.push([col, op, val]); return this; },
|
|
order() { return this; },
|
|
// The fake honours the filter it was given rather than merely accepting it.
|
|
range() {
|
|
const keep = applied.not.some(([c, o]) => c === 'lineage_action' && o === 'is')
|
|
? rows.filter((r) => r.lineage_action !== null) : rows;
|
|
return Promise.resolve({ data: keep, error: null });
|
|
},
|
|
};
|
|
const supabase = { from(t) { applied.table = t; return builder; } };
|
|
const row = { ...CAND, snapshot_id: 's' };
|
|
const out = await ret.attachLineage([row], { getClient: () => supabase, mintReadId: () => 'MINT' });
|
|
|
|
expect(applied.table).toBe('model_snapshots');
|
|
expect(applied.not).toContainEqual(['lineage_action', 'is', null]);
|
|
expect(applied.eq).toContainEqual(['sport', 'mlb']);
|
|
expect(applied.eq).toContainEqual(['game_date', '2026-08-28']);
|
|
// The projection must carry every field the predicate needs, or the guard
|
|
// would judge a row on columns it never asked for.
|
|
for (const f of VALID_LINEAGE_ACTION_FIELDS) expect(applied.select).toContain(f);
|
|
expect(out.lookup).toMatchObject({ scopes: 1 });
|
|
expect(out.error).toBeNull();
|
|
});
|
|
|
|
test('LAYER 1 — the in-loop check rejects an invalid row even if the query returned one', async () => {
|
|
const rows = [partialRow()];
|
|
const builder = {
|
|
select() { return this; }, eq() { return this; }, not() { return this; }, order() { return this; },
|
|
range() { return Promise.resolve({ data: rows, error: null }); },
|
|
};
|
|
const row = { ...CAND, snapshot_id: 's' };
|
|
const out = await ret.attachLineage([row], { getClient: () => ({ from: () => builder }), mintReadId: () => 'MINT' });
|
|
expect(out.lookup.invalid_excluded).toBe(1);
|
|
expect(out.lookup.valid_actions).toBe(0);
|
|
expect(row.lineage_state).toBe('LIVE');
|
|
});
|
|
});
|
|
|
|
describe('the claim digest is frozen', () => {
|
|
test('a known claim hashes to a known digest', () => {
|
|
// Pins the digest itself. Dropping or adding a CLAIM field silently changes
|
|
// every future comparison, and nothing else in the suite would notice.
|
|
const claim = {
|
|
line: 0.5, side: 'over', book: 'draftkings', over_odds: -115, under_odds: 100,
|
|
p_win: 0.9, grade: 'B+', confidence: 90, confidence_basis: 'p_win',
|
|
ev_pct: 1.5, projection: 1.2, edge_pct: 3, takeable: true, value: false,
|
|
fair_odds: -122, fair_prob: 0.55, refused: false, refusal_reason: null,
|
|
};
|
|
expect(R.claimDigest(claim)).toBe(R.claimDigest({ ...claim }));
|
|
expect(R.CLAIM_MARKET_FIELDS).toEqual(['line', 'side', 'book', 'locked_odds', 'over_odds', 'under_odds']);
|
|
// Every market term must move the digest: a field that cannot change it is
|
|
// a field the chronology is blind to.
|
|
for (const f of R.CLAIM_MARKET_FIELDS) {
|
|
const bumped = { ...claim, [f]: f === 'side' ? 'under' : 999 };
|
|
expect(R.claimDigest(bumped)).not.toBe(R.claimDigest(claim));
|
|
}
|
|
expect(R.CLAIM_SCHEMA_VERSION).toBe('claim@1');
|
|
expect(R.DIGEST_ALGORITHM_VERSION).toBe('sha256-json-sorted@1');
|
|
});
|
|
});
|