From 7b62896ef2a1bded7eb5708807dd3e9630535150 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Sat, 20 Jun 2026 13:51:40 +0800 Subject: [PATCH 1/4] Migrate formula-table onto the field-add lifecycle driver formula-table now delegates to field-add-lifecycle.ts as the family's fourth member: prepareFixture seeds the numeric source table (parking its createTable/seedRecords measurements on the fixture), assertSeedReady waits for source-row readability, runPrimary creates the formula field(s) and waits for the computed backfill + full scan, and cleanup restores the reusable seed by deleting the added formula fields (or drops the table). The shared driver is unchanged: the source-ready phase relabel (sourceReady on execute vs seedReady on seed) and the trailing formulaReady phase append are expressed in the spec, reusing the unchanged buildFormulaCaseResult so artifacts stay byte-equivalent. G1: formula/10k-calc and formula/10k-5-concurrent x v1,v2 diff clean vs baseline. Masks: details.seed.seedTableName (the hash-suffixed seed name, a seedHash-family content address that only appears in G1, never in baseline A<->B) and details.{formula,formulas[],formulaResults[]}.compiledExpression (generated A/B/C field ids embedded in the compiled form; uncompiled expression stays visible). Co-Authored-By: Claude --- framework/runners/formula-table.runner.ts | 380 ++++++++++------------ scripts/diff-artifacts.mjs | 28 +- 2 files changed, 198 insertions(+), 210 deletions(-) diff --git a/framework/runners/formula-table.runner.ts b/framework/runners/formula-table.runner.ts index 735f78b..7d76410 100644 --- a/framework/runners/formula-table.runner.ts +++ b/framework/runners/formula-table.runner.ts @@ -25,7 +25,11 @@ import type { PerfRunContext, PerfRunResult, } from "../types"; -import { PerfRunDiagnosticError } from "../types"; +import { + runFieldAddLifecycle, + seedFieldAddLifecycle, + type FieldAddLifecycleSpec, +} from "./field-add-lifecycle"; const chunk = (items: T[], size: number) => { const chunks: T[][] = []; @@ -1039,14 +1043,11 @@ const buildFormulaSeedFixture = async ( } }; -export const seedFormulaTableCase = async ( +const buildFormulaSeedCache = ( perfCase: PerfCase, - context: PerfRunContext, -): Promise => { - const config = perfCase.config as FormulaTableCaseConfig; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.tableNamePrefix}-seed-${Date.now()}`; - const seedCacheInfo = await buildSeedCacheInfo({ + config: FormulaTableCaseConfig, +) => + buildSeedCacheInfo({ perfCase, runner: "formula-table", fixtureVersion: "formula-table-v1", @@ -1056,66 +1057,58 @@ export const seedFormulaTableCase = async ( new URL("../seed-cache.ts", import.meta.url), ], }); - const seedFixture = await buildFormulaSeedFixture( - perfCase, - context, - baseId, - tableName, - config, - seedCacheInfo, - ); - const sourceReadyMeasurement = await measureAsync("seedReady", () => - waitForSourceSamples( - seedFixture.tableId, - seedFixture.sourceFields, - config, - seedFixture.sampleRecords, - ), - ); - return buildFormulaCaseResult({ - config, - tableId: seedFixture.tableId, - tableName: seedFixture.tableName, - batches: seedFixture.batches, - batchDurations: seedFixture.batchDurations, - sampleRecords: seedFixture.sampleRecords, - createTableMeasurement: seedFixture.createTableMeasurement, - seedMeasurement: seedFixture.seedMeasurement, - sourceReadyMeasurement, - seedCacheInfo, - seedCacheHit: seedFixture.seedCacheHit, - reusableSeed: seedFixture.reusable, - sourceFields: seedFixture.sourceFields, - formulas: buildCompiledFormulas( - config, - await getFields(seedFixture.tableId), - ), - }); +const sourceFieldsArray = (sourceFields: SourceFields) => + sourceFieldNames.map((fieldName) => sourceFields[fieldName]); + +type FormulaLifecycleFixture = FormulaSeedFixture & { + // The source-readiness phase the legacy runner names differently per mode: + // "seedReady" on the seed (prepare-DB) path, "sourceReady" on the measured + // execute path. The driver always measures it as "seedReady"; the runner + // relabels the emitted phase from this, keeping the shared driver byte-stable. + sourceReadyName: string; + // Compiled (no-id) formulas for the seed / diagnostic details.formulas, where + // the formula fields have not been created yet. Compiled from the source + // Title/A/B/C ids, which is exactly what the formula expressions reference, so + // it matches the legacy seed path's buildCompiledFormulas(getFields()) output. + compiledFormulas: CompiledFormula[]; + // Created formula field ids, pushed as runPrimary creates each field and read + // by cleanup to restore a reusable seed (delete the added formula fields). + createdFormulaFieldIds: string[]; }; -export const runFormulaTableCase = async ( - perfCase: PerfCase, - context: PerfRunContext, -): Promise => { - const config = perfCase.config as FormulaTableCaseConfig; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.tableNamePrefix}-${Date.now()}`; - const seedCacheInfo = await buildSeedCacheInfo({ - perfCase, - runner: "formula-table", - fixtureVersion: "formula-table-v1", - seedConfig: getFormulaSeedConfig(config), - seedCodeFiles: [ - new URL(import.meta.url), - new URL("../seed-cache.ts", import.meta.url), - ], - }); - let tableId = ""; - let reusableSeed = false; - const createdFormulaFieldIds: string[] = []; +type FormulaSourceReadyResult = Awaited< + ReturnType +>; - try { +type FormulaPrimary = { + formulasReadyMeasurement: Measurement; + fullFormulaScanReadyMeasurement: Measurement< + Awaited> + >; +}; + +// formula-table rides the field-add lifecycle as the fourth member, with the +// widest primary in the family: seed a numeric source table, wait for the source +// rows to be readable, create N formula fields (each its own trace step) and +// wait for each to backfill its sampled values, full-scan every computed value, +// then restore the seed by deleting the added formula fields. Like field-create, +// its prepare carries its own createTable/seedRecords measurements so the driver +// emits no "prepare" phase. Its source-readiness phase is named "sourceReady" on +// execute vs "seedReady" on the seed path, and its primary metric is a computed +// sum (formulasReady + fullScan) with a trailing appended formulaReady phase — +// all expressed in the spec, leaving field-add-lifecycle byte-stable. +const formulaTableFieldAddSpec: FieldAddLifecycleSpec< + FormulaTableCaseConfig, + FormulaLifecycleFixture, + FormulaSourceReadyResult, + FormulaPrimary +> = { + prepareFixture: async ({ perfCase, context, baseId, config, seedMode }) => { + const seedCacheInfo = await buildFormulaSeedCache(perfCase, config); + const tableName = seedMode + ? `${config.tableNamePrefix}-seed-${Date.now()}` + : `${config.tableNamePrefix}-${Date.now()}`; const seedFixture = await buildFormulaSeedFixture( perfCase, context, @@ -1124,159 +1117,128 @@ export const runFormulaTableCase = async ( config, seedCacheInfo, ); - tableId = seedFixture.tableId; - reusableSeed = seedFixture.reusable; - const { - tableName: seedTableName, - sourceFields, - sampleRecords, - batches, - batchDurations, - createTableMeasurement, - seedMeasurement, - seedCacheHit, - } = seedFixture; - const tableFields = await getFields(tableId); - const formulas = buildCompiledFormulas(config, tableFields); - let sourceReadyMeasurement: Awaited< - ReturnType< - typeof measureAsync>> - > - >; - - try { - sourceReadyMeasurement = await measureAsync("sourceReady", () => - waitForSourceSamples(tableId, sourceFields, config, sampleRecords), - ); - } catch (error) { - const diagnosticResult = buildFormulaCaseResult({ + return Object.assign(seedFixture, { + sourceReadyName: seedMode ? "seedReady" : "sourceReady", + compiledFormulas: buildCompiledFormulas( config, - tableId, - tableName: seedTableName, - batches, - batchDurations, - sampleRecords, - createTableMeasurement, - seedMeasurement, - seedCacheInfo, - seedCacheHit, - reusableSeed, - sourceFields, - formulas, - error, - }); - - throw new PerfRunDiagnosticError( - error instanceof Error ? error.message : String(error), - diagnosticResult, - ); - } - - let formulasReadyMeasurement: Measurement; - let fullFormulaScanReadyMeasurement: Measurement< - Awaited> - >; - const createdFormulaFields = new Map< - string, - FormulaFieldCaseConfig & { id: string; compiledExpression: string } - >(); - - try { - formulasReadyMeasurement = await measureAsync("formulasReady", () => - createFormulaFieldsAndWaitForSamples( - context, - perfCase, - tableId, - formulas, - sampleRecords, + sourceFieldsArray(seedFixture.sourceFields), + ), + createdFormulaFieldIds: [] as string[], + }); + }, + assertSeedReady: ({ fixture, config }) => + waitForSourceSamples( + fixture.tableId, + fixture.sourceFields, + config, + fixture.sampleRecords, + ), + runPrimary: async ({ perfCase, context, fixture, config }) => { + const formulasReadyMeasurement = await measureAsync("formulasReady", () => + createFormulaFieldsAndWaitForSamples( + context, + perfCase, + fixture.tableId, + fixture.compiledFormulas, + fixture.sampleRecords, + config, + (createdFormula) => { + fixture.createdFormulaFieldIds.push(createdFormula.id); + }, + ), + ); + const fullFormulaScanReadyMeasurement = await measureAsync( + "fullFormulaScanReady", + () => + waitForFormulaFullScan( + fixture.tableId, + formulasReadyMeasurement.result.map(({ formula }) => formula), config, - (createdFormula) => { - createdFormulaFieldIds.push(createdFormula.id); - createdFormulaFields.set(createdFormula.name, createdFormula); - }, + fixture.sourceFields, ), - ); - fullFormulaScanReadyMeasurement = await measureAsync( - "fullFormulaScanReady", - () => - waitForFormulaFullScan( - tableId, - formulasReadyMeasurement.result.map(({ formula }) => formula), - config, - sourceFields, - ), - ); - } catch (error) { - const formulasWithCreatedIds = formulas.map((formula) => { - const createdFormula = createdFormulaFields.get(formula.name); - return createdFormula ?? formula; - }); - const diagnosticResult = buildFormulaCaseResult({ - config, - tableId, - tableName: seedTableName, - batches, - batchDurations, - sampleRecords, - createTableMeasurement, - seedMeasurement, - sourceReadyMeasurement, - seedCacheInfo, - seedCacheHit, - reusableSeed, - sourceFields, - formulas: formulasWithCreatedIds, - formulasReadyMeasurement, - error, - }); - - throw new PerfRunDiagnosticError( - error instanceof Error ? error.message : String(error), - diagnosticResult, - ); + ); + return { formulasReadyMeasurement, fullFormulaScanReadyMeasurement }; + }, + buildResult: ({ config, fixture, seedReadyMeasurement, primary, error }) => { + if (!fixture) { + // Unreachable in the driver's flow (a fixture always exists before + // seedReady/runPrimary run), kept only to satisfy the optional-fixture + // type without crashing on the source-field accesses below. + return { + metrics: {}, + thresholds: [], + phases: [], + details: { + error: + error instanceof Error + ? { name: error.name, message: error.message } + : undefined, + }, + }; } - + const sourceReadyMeasurement = seedReadyMeasurement + ? { + name: fixture.sourceReadyName, + durationMs: seedReadyMeasurement.durationMs, + result: seedReadyMeasurement.result, + } + : undefined; + const formulas = primary + ? primary.formulasReadyMeasurement.result.map(({ formula }) => formula) + : fixture.compiledFormulas; const result = buildFormulaCaseResult({ config, - tableId, - tableName: seedTableName, - batches, - batchDurations, - sampleRecords, - createTableMeasurement, - seedMeasurement, + tableId: fixture.tableId, + tableName: fixture.tableName, + batches: fixture.batches, + batchDurations: fixture.batchDurations, + sampleRecords: fixture.sampleRecords, + createTableMeasurement: fixture.createTableMeasurement, + seedMeasurement: fixture.seedMeasurement, sourceReadyMeasurement, - seedCacheInfo, - seedCacheHit, - reusableSeed, - sourceFields, - formulas: formulasReadyMeasurement.result.map(({ formula }) => formula), - formulasReadyMeasurement, - fullFormulaScanReadyMeasurement, + seedCacheInfo: fixture.seedCacheInfo, + seedCacheHit: fixture.seedCacheHit, + reusableSeed: fixture.reusable, + sourceFields: fixture.sourceFields, + formulas, + formulasReadyMeasurement: primary?.formulasReadyMeasurement, + fullFormulaScanReadyMeasurement: primary?.fullFormulaScanReadyMeasurement, + error, }); - + if (!primary) { + return result; + } + // The legacy execute path appends the formula-ready phase AFTER + // fullFormulaScanReady (matching its original phase order), so reproduce it + // here verbatim instead of folding it into buildFormulaCaseResult. return { ...result, phases: [ ...(result.phases ?? []), { name: - formulasReadyMeasurement.result.length === 1 + primary.formulasReadyMeasurement.result.length === 1 ? "formulaReady" - : formulasReadyMeasurement.name, - durationMs: formulasReadyMeasurement.durationMs, + : primary.formulasReadyMeasurement.name, + durationMs: primary.formulasReadyMeasurement.durationMs, }, ], }; - } finally { + }, + cleanup: async ({ baseId, fixture }) => { // CI execute jobs run on a disposable restored DB copy; cleanup that only // tidies the durable database is skipped there. - if (isExecuteDbIsolated()) { - // discarded with the database - } else if (reusableSeed) { - for (const fieldId of createdFormulaFieldIds.reverse()) { + if (isExecuteDbIsolated() || !fixture) { + return; + } + if (fixture.reusable) { + // Restore the reusable seed by deleting the added formula fields (the same + // source-fields-only invariant the seed asserts). Reverse so dependent + // fields drop before their dependencies; idempotent and a no-op when the + // create made nothing. + for (const fieldId of [...fixture.createdFormulaFieldIds].reverse()) { try { - await deleteField(tableId, fieldId); + await deleteField(fixture.tableId, fieldId); } catch (error) { console.warn( `Failed to cleanup perf formula field ${fieldId}`, @@ -1284,12 +1246,24 @@ export const runFormulaTableCase = async ( ); } } - } else if (tableId) { - try { - await permanentDeleteTable(baseId, tableId); - } catch (error) { - console.warn(`Failed to cleanup perf table ${tableId}`, error); - } + return; } - } + try { + await permanentDeleteTable(baseId, fixture.tableId); + } catch (error) { + console.warn(`Failed to cleanup perf table ${fixture.tableId}`, error); + } + }, }; + +export const seedFormulaTableCase = ( + perfCase: PerfCase, + context: PerfRunContext, +): Promise => + seedFieldAddLifecycle(perfCase, context, formulaTableFieldAddSpec); + +export const runFormulaTableCase = ( + perfCase: PerfCase, + context: PerfRunContext, +): Promise => + runFieldAddLifecycle(perfCase, context, formulaTableFieldAddSpec); diff --git a/scripts/diff-artifacts.mjs b/scripts/diff-artifacts.mjs index 58cdbd5..6ad86b9 100644 --- a/scripts/diff-artifacts.mjs +++ b/scripts/diff-artifacts.mjs @@ -224,20 +224,22 @@ const shouldMaskKey = (path, key) => { return true; } - // conditional-lookup emits its seed-cache key directly under details.seed - // (seedHash / seedHashShort), where every other migrated runner nests it under + // conditional-lookup and formula-table emit their seed-cache key directly under + // details.seed (seedHash / seedHashShort, plus seedTableName whose suffix IS + // the hash for formula-table), where every other migrated runner nests it under // a `cache` object masked by the rule above. Like that key, this is a content // address that digests both the seed config AND the seed code files: it is - // stable run-to-run on unchanged code (the conditional-lookup baseline A vs B - // artifacts are identical), but it legitimately changes when the runner is - // refactored — so masking it lets a behavior-preserving migration pass the G1 - // diff, exactly as the cache.seedHash rule already does for the other migrated + // stable run-to-run on unchanged code (the conditional-lookup and formula-table + // baseline A vs B artifacts are identical — seedTableName never appears there), + // but it legitimately changes when the runner is refactored — so masking it + // lets a behavior-preserving migration pass the G1 diff, exactly as the + // cache.seedHash / cache.seedTableName rule already does for the other migrated // runners. The semantic seed identity stays visible via // details.seed.seedNamePrefix / schemaSignature, details.recordCount / // batchSize, and the verifiedSamples expected values. if ( pathEquals(path, ["details", "seed"]) && - ["seedHash", "seedHashShort"].includes(key) + ["seedHash", "seedHashShort", "seedTableName"].includes(key) ) { return true; } @@ -341,6 +343,18 @@ const shouldMaskKey = (path, key) => { return true; } + // formula-table compiled formula expressions embed the generated A/B/C source + // field ids, so details.formula.compiledExpression, + // details.formulas[].compiledExpression, and + // details.formulaResults[].compiledExpression differ between two runs of + // unchanged code (confirmed by the formula-table baseline A vs B diff). The + // compiled form is never the semantic field — the uncompiled `expression` and + // `expected` kind stay visible — so masking it everywhere under details is + // safe, mirroring the field-create verifiedFields[].expression rule. + if (path[0] === "details" && key === "compiledExpression") { + return true; + } + return false; }; From d6c9480724fe1fb09f908bfe10736f60b2571791 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Sat, 20 Jun 2026 13:51:57 +0800 Subject: [PATCH 2/4] Migrate record-update-attachment onto the record-mutation lifecycle driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit record-update-attachment now delegates to record-mutation-lifecycle.ts (the record-update family, useRecordWindow:false): prepareFixture seeds the Title + empty-Attachment table, assertSeedReady asserts the cells are empty, and the measured operation uploads the attachment tokens (execute-only — the seed->execute cache carries no storage volume, so tokens must never be cached), warms up, p95-samples the idempotent bulk attachment-cell update, asserts routing, and verifies. uploadSetupMs is parked on the fixture so buildResult still reports it on the diagnostic path. The shared driver is unchanged and the unchanged result-assembly keeps artifacts byte-equivalent. G1: record-update/attachment-insert-100 x v1,v2 diff clean vs baseline with no new seedHash mask (its seed hash already nests under details.seed.cache). Masks: details.request.attachmentFieldId and details.update.expectedTokens (generated field id + random upload tokens, noise confirmed by baseline A<->B). Co-Authored-By: Claude --- .../record-update-attachment.runner.ts | 345 +++++++++--------- scripts/diff-artifacts.mjs | 15 + 2 files changed, 197 insertions(+), 163 deletions(-) diff --git a/framework/runners/record-update-attachment.runner.ts b/framework/runners/record-update-attachment.runner.ts index 9ec0681..dc900a6 100644 --- a/framework/runners/record-update-attachment.runner.ts +++ b/framework/runners/record-update-attachment.runner.ts @@ -36,7 +36,11 @@ import type { PerfRunResult, RecordUpdateAttachmentCaseConfig, } from "../types"; -import { PerfRunDiagnosticError } from "../types"; +import { + runRecordMutationLifecycle, + seedRecordMutationLifecycle, + type RecordMutationLifecycleSpec, +} from "./record-mutation-lifecycle"; const ATTACHMENT_FIXTURE_VERSION = "record-update-attachment-v1"; const ATTACHMENT_METADATA_PREFIX = "perf-lab-record-update-attachment:"; @@ -74,6 +78,10 @@ type AttachmentFixture = { seedCacheInfo: SeedCacheInfo; seedCacheHit: boolean; reusableSeed: boolean; + // Parked by the measured operation (the execute-only attachment upload), read + // by buildResult so the unmeasured upload cost is still reported even on the + // diagnostic path when a later step throws. + uploadSetupMs?: number; }; type AttachmentPrimaryResult = { @@ -722,186 +730,197 @@ const buildResult = ({ }, }); -export const runRecordUpdateAttachmentCase = async ( +// The single measured operation, run inside the driver's execute path only: +// upload tokens (execute-only setup) -> warmup -> p95-sampled bulk +// attachment-cell update -> routing assertion -> post-update verification + full +// scan, bundled into one synthetic primary measurement whose p95 duration is the +// primary metric. The attachment tokens MUST be uploaded here (execute), never +// seeded: the seed->execute cache carries only the DB dump, not the storage +// volume, so a cached token would 404 on the execute runner. uploadSetupMs is +// parked on the (mutable) fixture so buildResult still reports it even when a +// later step throws (the diagnostic path). +const runAttachmentMeasuredOperation = async ( perfCase: PerfCase, context: PerfRunContext, -): Promise => { - const config = perfCase.config as RecordUpdateAttachmentCaseConfig; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.tableNamePrefix}-${Date.now()}`; - let prepareMeasurement: Measurement | undefined; - let seedReadyMeasurement: Measurement<{ checkedRecords: number }> | undefined; - let uploadSetupMs: number | undefined; - let fixture: AttachmentFixture | undefined; - - try { - prepareMeasurement = await measureAsync("prepare", () => - prepareAttachmentFixture(baseId, tableName, config, perfCase), + config: RecordUpdateAttachmentCaseConfig, + fixture: AttachmentFixture, +): Promise> => { + const uploadMeasurement = await measureAsync("uploadSetup", () => + uploadAttachmentSet(fixture, config), + ); + fixture.uploadSetupMs = uploadMeasurement.durationMs; + const insertItems = uploadMeasurement.result.slice( + 0, + config.attachmentsPerCell, + ); + if (insertItems.length !== config.attachmentsPerCell) { + throw new Error( + `Uploaded ${uploadMeasurement.result.length} attachments, need ${config.attachmentsPerCell} per cell`, ); - fixture = prepareMeasurement.result; - seedReadyMeasurement = await measureAsync("seedReady", () => - assertSeedSamples(fixture!, config), + } + const expectedTokens = insertItems.map((item) => item.token); + + // Warmup: one unmeasured bulk insert so the per-request v2 + // container/context construction, prepared statements, and connection + // pools are hot before the sampled requests. The update is idempotent + // (the same tokens are written every time), so warmup and all samples + // leave the same final state the verification then checks. + const warmupMeasurement = await withPerfTraceStep( + context, + perfCase, + "warmup", + () => + measureAsync("warmupUpdate", () => + bulkInsertAttachments(fixture, insertItems), + ), + ); + + const samples = getPositiveIntegerEnv("PERF_LAB_SAMPLES") ?? config.samples; + const durations: number[] = []; + let lastUpdate = warmupMeasurement.result; + for (let iteration = 1; iteration <= samples; iteration += 1) { + const sampleMeasurement = await withPerfTraceStep( + context, + perfCase, + `sample-${iteration}`, + () => + measureAsync(`sample-${iteration}`, () => + bulkInsertAttachments(fixture, insertItems), + ), ); + durations.push(sampleMeasurement.durationMs); + lastUpdate = sampleMeasurement.result; + } + const summary = summarizeDurations(durations); + const routing = assertEngineRouting(context, lastUpdate.responseHeaders, { + operation: "updateRecords", + }); + const verifyMeasurement = await measureAsync("verifyUpdated", () => + assertInsertedSamples(fixture, config, expectedTokens), + ); + const fullScan = await assertInsertedFullScan( + fixture, + config, + expectedTokens, + ); + + return { + name: config.threshold.metric, + durationMs: summary.p95Ms, + result: { + summary, + samples, + warmupUpdateMs: warmupMeasurement.durationMs, + requestedRecords: lastUpdate.requestedRecords, + updatedRecords: lastUpdate.updatedRecords, + responseHeaders: lastUpdate.responseHeaders, + routing, + expectedTokens, + verified: { checkedRecords: verifyMeasurement.result.checkedRecords }, + verifyUpdatedMs: verifyMeasurement.durationMs, + fullScan, + }, + }; +}; - let primaryMeasurement: Measurement | undefined; +// Class C cleanup: clear the inserted attachment cells so the cached seed +// returns to its empty-attachment state; if that fails, delete the table. +// Isolated CI execute DBs are discarded, so skip all cleanup there. +const cleanupAttachmentFixture = async ({ + baseId, + fixture, + config, +}: { + baseId: string; + fixture: AttachmentFixture | undefined; + config: RecordUpdateAttachmentCaseConfig; +}) => { + if (isExecuteDbIsolated()) { + return; + } + if (fixture?.reusableSeed) { + let restored = false; try { - const uploadMeasurement = await measureAsync("uploadSetup", () => - uploadAttachmentSet(fixture!, config), - ); - uploadSetupMs = uploadMeasurement.durationMs; - const insertItems = uploadMeasurement.result.slice( - 0, - config.attachmentsPerCell, - ); - if (insertItems.length !== config.attachmentsPerCell) { - throw new Error( - `Uploaded ${uploadMeasurement.result.length} attachments, need ${config.attachmentsPerCell} per cell`, - ); - } - const expectedTokens = insertItems.map((item) => item.token); - - // Warmup: one unmeasured bulk insert so the per-request v2 - // container/context construction, prepared statements, and connection - // pools are hot before the sampled requests. The update is idempotent - // (the same tokens are written every time), so warmup and all samples - // leave the same final state the verification then checks. - const warmupMeasurement = await withPerfTraceStep( - context, - perfCase, - "warmup", - () => - measureAsync("warmupUpdate", () => - bulkInsertAttachments(fixture!, insertItems), - ), + await clearAttachmentCells(fixture); + await assertSeedSamples(fixture, config); + restored = true; + } catch (error) { + console.warn( + `Failed to restore cached attachment seed ${fixture.tableId}; deleting it`, + error, ); - - const samples = - getPositiveIntegerEnv("PERF_LAB_SAMPLES") ?? config.samples; - const durations: number[] = []; - let lastUpdate = warmupMeasurement.result; - for (let iteration = 1; iteration <= samples; iteration += 1) { - const sampleMeasurement = await withPerfTraceStep( - context, - perfCase, - `sample-${iteration}`, - () => - measureAsync(`sample-${iteration}`, () => - bulkInsertAttachments(fixture!, insertItems), - ), + } + if (!restored) { + try { + await permanentDeleteTable(baseId, fixture.tableId); + } catch (error) { + console.warn( + `Failed to cleanup attachment table ${fixture.tableId}`, + error, ); - durations.push(sampleMeasurement.durationMs); - lastUpdate = sampleMeasurement.result; } - const summary = summarizeDurations(durations); - const routing = assertEngineRouting(context, lastUpdate.responseHeaders, { - operation: "updateRecords", - }); - const verifyMeasurement = await measureAsync("verifyUpdated", () => - assertInsertedSamples(fixture!, config, expectedTokens), - ); - const fullScan = await assertInsertedFullScan( - fixture!, - config, - expectedTokens, - ); - primaryMeasurement = { - name: config.threshold.metric, - durationMs: summary.p95Ms, - result: { - summary, - samples, - warmupUpdateMs: warmupMeasurement.durationMs, - requestedRecords: lastUpdate.requestedRecords, - updatedRecords: lastUpdate.updatedRecords, - responseHeaders: lastUpdate.responseHeaders, - routing, - expectedTokens, - verified: { checkedRecords: verifyMeasurement.result.checkedRecords }, - verifyUpdatedMs: verifyMeasurement.durationMs, - fullScan, - }, - }; + } + } else if (fixture) { + try { + await permanentDeleteTable(baseId, fixture.tableId); } catch (error) { - throw new PerfRunDiagnosticError( - error instanceof Error ? error.message : String(error), - buildResult({ - config, - fixture, - prepareMeasurement, - seedReadyMeasurement, - uploadSetupMs, - primaryMeasurement, - error, - }), + console.warn( + `Failed to cleanup attachment table ${fixture.tableId}`, + error, ); } + } +}; - return buildResult({ +// record-update-attachment rides the record-mutation lifecycle: seed a Title + +// empty-Attachment table, assert the cells are empty, then run the single +// measured bulk attachment-cell update and restore-or-delete the seed. It varies +// from the scalar record-update member only in the measured operation (an +// execute-only upload + p95-sampled idempotent insert) and in opening no record +// window, so it expresses both as spec fields and the driver stays unchanged. +const recordUpdateAttachmentSpec: RecordMutationLifecycleSpec< + RecordUpdateAttachmentCaseConfig, + AttachmentFixture, + Awaited>, + AttachmentPrimaryResult +> = { + // The attachment bulk update is not grouped under a record window (matches the + // legacy runner, which opened none). + useRecordWindow: false, + prepareFixture: ({ baseId, tableName, config, perfCase }) => + prepareAttachmentFixture(baseId, tableName, config, perfCase), + assertSeedReady: ({ fixture, config }) => assertSeedSamples(fixture, config), + runMeasuredOperation: ({ perfCase, context, config, fixture }) => + runAttachmentMeasuredOperation(perfCase, context, config, fixture), + buildResult: ({ + config, + fixture, + prepareMeasurement, + seedReadyMeasurement, + primaryMeasurement, + error, + }) => + buildResult({ config, fixture, prepareMeasurement, seedReadyMeasurement, - uploadSetupMs, + uploadSetupMs: fixture?.uploadSetupMs, primaryMeasurement, - }); - } finally { - // Class C cleanup: clear the inserted attachment cells so the cached seed - // returns to its empty-attachment state; if that fails, delete the table. - // Isolated CI execute DBs are discarded, so skip all cleanup there. - if (!isExecuteDbIsolated()) { - if (fixture?.reusableSeed) { - let restored = false; - try { - await clearAttachmentCells(fixture); - await assertSeedSamples(fixture, config); - restored = true; - } catch (error) { - console.warn( - `Failed to restore cached attachment seed ${fixture.tableId}; deleting it`, - error, - ); - } - if (!restored) { - try { - await permanentDeleteTable(baseId, fixture.tableId); - } catch (error) { - console.warn( - `Failed to cleanup attachment table ${fixture.tableId}`, - error, - ); - } - } - } else if (fixture) { - try { - await permanentDeleteTable(baseId, fixture.tableId); - } catch (error) { - console.warn( - `Failed to cleanup attachment table ${fixture.tableId}`, - error, - ); - } - } - } - } + error, + }), + cleanup: ({ baseId, fixture, config }) => + cleanupAttachmentFixture({ baseId, fixture, config }), }; -export const seedRecordUpdateAttachmentCase = async ( +export const runRecordUpdateAttachmentCase = ( perfCase: PerfCase, - _context: PerfRunContext, -): Promise => { - const config = perfCase.config as RecordUpdateAttachmentCaseConfig; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.tableNamePrefix}-seed-${Date.now()}`; - const prepareMeasurement = await measureAsync("prepare", () => - prepareAttachmentFixture(baseId, tableName, config, perfCase), - ); - const seedReadyMeasurement = await measureAsync("seedReady", () => - assertSeedSamples(prepareMeasurement.result, config), - ); - return buildResult({ - config, - fixture: prepareMeasurement.result, - prepareMeasurement, - seedReadyMeasurement, - }); -}; + context: PerfRunContext, +): Promise => + runRecordMutationLifecycle(perfCase, context, recordUpdateAttachmentSpec); + +export const seedRecordUpdateAttachmentCase = ( + perfCase: PerfCase, + context: PerfRunContext, +): Promise => + seedRecordMutationLifecycle(perfCase, context, recordUpdateAttachmentSpec); diff --git a/scripts/diff-artifacts.mjs b/scripts/diff-artifacts.mjs index 6ad86b9..986851f 100644 --- a/scripts/diff-artifacts.mjs +++ b/scripts/diff-artifacts.mjs @@ -355,6 +355,21 @@ const shouldMaskKey = (path, key) => { return true; } + // record-update-attachment generated attachment field id and uploaded tokens. + // Each run seeds a fresh table (new attachment field id) and uploads fresh + // files (new random tokens), so details.request.attachmentFieldId and + // details.update.expectedTokens differ between two runs of unchanged code + // (confirmed by the record-update-attachment baseline A vs B diff). The + // semantic evidence stays visible: rowCount / attachmentsPerCell, + // update.requestedRecords / updatedRecords, routing, sampleVerification, and + // fullScan. + if (pathEquals(path, ["details", "request"]) && key === "attachmentFieldId") { + return true; + } + if (pathEquals(path, ["details", "update"]) && key === "expectedTokens") { + return true; + } + return false; }; From 1f8e52a8549d41e8c51b30a5f36e2cbabee4d4c7 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Sat, 20 Jun 2026 13:52:20 +0800 Subject: [PATCH 3/4] Create duplicate-lifecycle driver and migrate duplicate-table onto it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No existing driver fits "duplicate an entire seeded entity", so this adds a new, minimal framework/runners/duplicate-lifecycle.ts as the duplicate family's FIRST member (duplicate-base is the deferred second member). Its skeleton mirrors field-add-lifecycle (prepare/seedReady -> measured primary -> diagnostic wrap -> cleanup in finally) but its cleanup DROPS the created copy (read from the mutable fixture) and the source unless it is a reusable cached seed — drop-the-copy, not restore-by-delete. Per runner-framework.md it is intentionally family-shaped, not universal. duplicate-table delegates to it: prepareFixture seeds/restores the source table (parking its "prepare" measurement), assertSeedReady full-scans the source, and runPrimary runs the trace-wrapped duplicateTableTotalReady measurement (request + copy-readiness scan), parking the created copy id for cleanup. The unchanged buildDuplicateTableCaseResult keeps artifacts byte-equivalent. G1: duplicate-table/10k-20f and duplicate-table/10k-25f-5formula x v1,v2 diff clean vs baseline with no new seedHash mask (its seed hash already nests under details.prepare.cache). Masks: details.sourceFields[].id, details.sourceFormulas[].id, details.duplicate.duplicatedFormulaFields[].id (generated field ids, noise confirmed by baseline A<->B; names stay visible). Co-Authored-By: Claude --- framework/runners/duplicate-lifecycle.ts | 201 ++++++++++++++++++++ framework/runners/duplicate-table.runner.ts | 156 +++++++-------- scripts/diff-artifacts.mjs | 18 ++ 3 files changed, 297 insertions(+), 78 deletions(-) create mode 100644 framework/runners/duplicate-lifecycle.ts diff --git a/framework/runners/duplicate-lifecycle.ts b/framework/runners/duplicate-lifecycle.ts new file mode 100644 index 0000000..a87c992 --- /dev/null +++ b/framework/runners/duplicate-lifecycle.ts @@ -0,0 +1,201 @@ +import { measureAsync } from "../metrics"; +import { PerfRunDiagnosticError } from "../types"; +import type { PerfCase, PerfRunContext, PerfRunResult } from "../types"; +import type { Measurement } from "./record-undo-redo.shared"; + +// The lifecycle skeleton shared by the duplicate family: seed (or restore) a +// populated source entity, assert it is in its expected pre-duplicate shape, run +// one measured duplicate request and verify the produced copy, then drop the +// copy (and the source unless it is a reusable cached seed). duplicate-table is +// the first runner kind on it (duplicate a seeded source table); duplicate-base +// is the deferred second member, so the shape is born from one member and proven +// generic only when the family actually grows. +// +// The driver owns the repeated protocol: +// prepare(seed) -> seedReady -> measured duplicate + verify -> build result +// (twice: diagnostic catch + success) -> finally drop-copy(+source) cleanup. +// +// Two deliberate choices keep this family honest, mirroring field-add-lifecycle: +// * The driver emits NO "prepare" phase. The prepare step carries its own +// create/seed sub-measurement on the returned fixture (named "prepare", or a +// synthetic seedBuild/seedRestore marker on the seed path), so the runner +// owns it and surfaces it from buildResult. +// * The driver does NOT wrap runPrimary in a single measureAsync(metric). A +// duplicate runner's primary is multi-phase (the trace-wrapped duplicate +// request, then a copy-readiness full scan) feeding a computed threshold +// metric, so runPrimary owns its own trace step(s) and measurement and +// returns the bundle buildResult unpacks. +// +// Cleanup is Class C drop-or-keep: the measured operation CREATES a brand-new +// duplicate entity, so cleanup always drops that copy, and additionally drops +// the source unless it is a reusable cached seed (which the next run reuses). +// The driver delegates the whole decision to the runner's cleanup (which holds +// the seed-cache + execute-isolation context and the created-copy id parked on +// the fixture), passing whether the primary was attempted. +// +// Scope note: duplicate-family-shaped, not a universal driver. It assumes the +// prepare step carries its own seed measurement (no "prepare" phase), a single +// measured duplicate operation against a reusable source fixture, and +// drop-the-copy cleanup. A broader abstraction should wait for the second member +// (duplicate-base) to prove the common shape. + +export type DuplicateLifecyclePrepareArgs = { + perfCase: PerfCase; + context: PerfRunContext; + baseId: string; + config: TConfig; + // True on the seed (prepare-DB) path, false on the measured execute path. Lets + // a runner pick its seed-vs-run source-name suffix; the fixture is otherwise + // opaque to the driver, so it may span more than one table/entity. + seedMode: boolean; +}; + +export type DuplicateLifecycleBuildResultArgs< + TConfig, + TFixture, + TSeedReady, + TPrimary, +> = { + config: TConfig; + fixture?: TFixture; + seedReadyMeasurement?: Measurement; + primary?: TPrimary; + error?: unknown; +}; + +export type DuplicateLifecycleSpec = { + // Build (or restore from the seed cache) the source entity the duplicate runs + // against. Carries its own create/seed measurement on the returned fixture, so + // the driver emits no "prepare" phase. + prepareFixture: ( + args: DuplicateLifecyclePrepareArgs, + ) => Promise; + // Assert the seeded source is in its expected pre-duplicate shape, emitted as + // the `seedReady` phase by the driver. + assertSeedReady: (args: { + baseId: string; + fixture: TFixture; + config: TConfig; + }) => Promise; + // The measured operation: the duplicate request (trace-wrapped), routing + // assertion, and the copy-readiness verification, bundled into the returned + // primary. The runner MUST park the created copy's id on the (mutable) fixture + // so cleanup can drop it even when verification throws after the copy exists. + // The driver does not wrap this in a phase — the runner's measurement becomes + // the phases and the (computed) primary metric in buildResult. + runPrimary: (args: { + perfCase: PerfCase; + context: PerfRunContext; + baseId: string; + fixture: TFixture; + config: TConfig; + }) => Promise; + // Assemble the artifact result. Called once on success and once inside the + // diagnostic-error path (with `error` set and `primary` possibly absent). + buildResult: ( + args: DuplicateLifecycleBuildResultArgs< + TConfig, + TFixture, + TSeedReady, + TPrimary + >, + ) => PerfRunResult; + // Drop the created copy (parked on the fixture) and the source unless it is a + // reusable cached seed. Runs in `finally`, so it must tolerate an undefined + // fixture (prepare failed) and a copy that was never created. + // `primaryAttempted` is true once the measured operation began. + cleanup: (args: { + baseId: string; + fixture: TFixture | undefined; + config: TConfig; + primaryAttempted: boolean; + }) => Promise; +}; + +export const seedDuplicateLifecycle = async < + TConfig, + TFixture, + TSeedReady, + TPrimary, +>( + perfCase: PerfCase, + context: PerfRunContext, + spec: DuplicateLifecycleSpec, +): Promise => { + const config = perfCase.config as unknown as TConfig; + const baseId = globalThis.testConfig.baseId; + const fixture = await spec.prepareFixture({ + perfCase, + context, + baseId, + config, + seedMode: true, + }); + const seedReadyMeasurement = await measureAsync("seedReady", () => + spec.assertSeedReady({ baseId, fixture, config }), + ); + + return spec.buildResult({ config, fixture, seedReadyMeasurement }); +}; + +export const runDuplicateLifecycle = async < + TConfig, + TFixture, + TSeedReady, + TPrimary, +>( + perfCase: PerfCase, + context: PerfRunContext, + spec: DuplicateLifecycleSpec, +): Promise => { + const config = perfCase.config as unknown as TConfig; + const baseId = globalThis.testConfig.baseId; + let fixture: TFixture | undefined; + let primaryAttempted = false; + + try { + fixture = await spec.prepareFixture({ + perfCase, + context, + baseId, + config, + seedMode: false, + }); + let seedReadyMeasurement: Measurement | undefined; + let primary: TPrimary | undefined; + + try { + seedReadyMeasurement = await measureAsync("seedReady", () => + spec.assertSeedReady({ baseId, fixture: fixture as TFixture, config }), + ); + primaryAttempted = true; + primary = await spec.runPrimary({ + perfCase, + context, + baseId, + fixture: fixture as TFixture, + config, + }); + } catch (error) { + throw new PerfRunDiagnosticError( + error instanceof Error ? error.message : String(error), + spec.buildResult({ + config, + fixture, + seedReadyMeasurement, + primary, + error, + }), + ); + } + + return spec.buildResult({ + config, + fixture, + seedReadyMeasurement, + primary, + }); + } finally { + await spec.cleanup({ baseId, fixture, config, primaryAttempted }); + } +}; diff --git a/framework/runners/duplicate-table.runner.ts b/framework/runners/duplicate-table.runner.ts index 1e8fbde..0463ba5 100644 --- a/framework/runners/duplicate-table.runner.ts +++ b/framework/runners/duplicate-table.runner.ts @@ -28,7 +28,11 @@ import type { PerfRunContext, PerfRunResult, } from "../types"; -import { PerfRunDiagnosticError } from "../types"; +import { + runDuplicateLifecycle, + seedDuplicateLifecycle, + type DuplicateLifecycleSpec, +} from "./duplicate-lifecycle"; type Measurement = { name: string; @@ -1086,106 +1090,102 @@ const buildDuplicateTableCaseResult = ({ }; }; -export const runDuplicateTableCase = async ( - perfCase: PerfCase, - context: PerfRunContext, -): Promise => { - const config = perfCase.config as DuplicateTableCaseConfig; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.sourceTableNamePrefix}-${Date.now()}`; - let prepareMeasurement: Measurement | undefined; - let seedReadyMeasurement: - | Measurement>> - | undefined; - let primaryMeasurement: Measurement | undefined; +type DuplicateTableLifecycleFixture = DuplicateTableFixture & { + // Parked by prepareFixture (the driver emits no "prepare" phase); buildResult + // rebuilds the prepare measurement from this. + prepareDurationMs: number; + // Parked by runPrimary once the duplicate request returns, so cleanup can drop + // the created copy even if the copy-readiness scan throws afterwards. + duplicateTableId?: string; +}; - try { - prepareMeasurement = await measureAsync("prepare", () => +// duplicate-table is the first member of the duplicate lifecycle: seed (or +// restore) a populated source table, assert it is fully readable, run the single +// measured duplicate request and wait for the copy's rows to be readable, then +// drop the copy (and the source unless it is a reusable cached seed). Its prepare +// carries its own "prepare" measurement (so the driver emits no "prepare" phase), +// and its primary is the trace-wrapped duplicateTableTotalReady measurement whose +// request/full-scan split feeds the computed metrics — all expressed in the spec, +// so the new driver is born minimal and family-shaped (duplicate-base joins next). +const duplicateTableSpec: DuplicateLifecycleSpec< + DuplicateTableCaseConfig, + DuplicateTableLifecycleFixture, + Awaited>, + Measurement +> = { + prepareFixture: async ({ baseId, config, perfCase, seedMode }) => { + const tableName = seedMode + ? `${config.sourceTableNamePrefix}-seed-${Date.now()}` + : `${config.sourceTableNamePrefix}-${Date.now()}`; + const prepareMeasurement = await measureAsync("prepare", () => prepareDuplicateTableFixture(baseId, tableName, config, perfCase), ); - seedReadyMeasurement = await measureAsync("seedReady", () => - assertSeedReady(prepareMeasurement!.result, config), + return Object.assign(prepareMeasurement.result, { + prepareDurationMs: prepareMeasurement.durationMs, + }); + }, + assertSeedReady: ({ fixture, config }) => assertSeedReady(fixture, config), + runPrimary: async ({ perfCase, context, baseId, fixture, config }) => { + const primaryMeasurement = await withPerfTraceStep( + context, + perfCase, + config.threshold.metric, + () => + measureAsync("duplicateTableTotalReady", () => + duplicateTableAndVerify(context, baseId, fixture, config), + ), ); - - try { - primaryMeasurement = await withPerfTraceStep( - context, - perfCase, - config.threshold.metric, - () => - measureAsync("duplicateTableTotalReady", () => - duplicateTableAndVerify( - context, - baseId, - prepareMeasurement!.result, - config, - ), - ), - ); - } catch (error) { - const diagnosticResult = buildDuplicateTableCaseResult({ - config, - prepareMeasurement, - seedReadyMeasurement, - primaryMeasurement, - error, - }); - - throw new PerfRunDiagnosticError( - error instanceof Error ? error.message : String(error), - diagnosticResult, - ); - } - + fixture.duplicateTableId = primaryMeasurement.result.duplicateTableId; + return primaryMeasurement; + }, + buildResult: ({ config, fixture, seedReadyMeasurement, primary, error }) => { + const prepareMeasurement = fixture + ? { + name: "prepare", + durationMs: fixture.prepareDurationMs, + result: fixture, + } + : undefined; return buildDuplicateTableCaseResult({ config, prepareMeasurement, seedReadyMeasurement, - primaryMeasurement, + primaryMeasurement: primary, + error, }); - } finally { - if (primaryMeasurement?.result.duplicateTableId && !isExecuteDbIsolated()) { + }, + cleanup: async ({ baseId, fixture }) => { + if (isExecuteDbIsolated() || !fixture) { + return; + } + if (fixture.duplicateTableId) { try { - await permanentDeleteTable( - baseId, - primaryMeasurement.result.duplicateTableId, - ); + await permanentDeleteTable(baseId, fixture.duplicateTableId); } catch (error) { console.warn( - `Failed to cleanup duplicated perf table ${primaryMeasurement.result.duplicateTableId}`, + `Failed to cleanup duplicated perf table ${fixture.duplicateTableId}`, error, ); } } - - const fixture = prepareMeasurement?.result; - if (fixture?.tableId && !fixture.reusableSeed && !isExecuteDbIsolated()) { + if (fixture.tableId && !fixture.reusableSeed) { try { await permanentDeleteTable(baseId, fixture.tableId); } catch (error) { console.warn(`Failed to cleanup perf table ${fixture.tableId}`, error); } } - } + }, }; -export const seedDuplicateTableCase = async ( +export const runDuplicateTableCase = ( perfCase: PerfCase, - _context: PerfRunContext, -): Promise => { - const config = perfCase.config as DuplicateTableCaseConfig; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.sourceTableNamePrefix}-seed-${Date.now()}`; - const prepareMeasurement = await measureAsync("prepare", () => - prepareDuplicateTableFixture(baseId, tableName, config, perfCase), - ); - const seedReadyMeasurement = await measureAsync("seedReady", () => - assertSeedReady(prepareMeasurement.result, config), - ); + context: PerfRunContext, +): Promise => + runDuplicateLifecycle(perfCase, context, duplicateTableSpec); - return buildDuplicateTableCaseResult({ - config, - prepareMeasurement, - seedReadyMeasurement, - }); -}; +export const seedDuplicateTableCase = ( + perfCase: PerfCase, + context: PerfRunContext, +): Promise => + seedDuplicateLifecycle(perfCase, context, duplicateTableSpec); diff --git a/scripts/diff-artifacts.mjs b/scripts/diff-artifacts.mjs index 986851f..fd306a6 100644 --- a/scripts/diff-artifacts.mjs +++ b/scripts/diff-artifacts.mjs @@ -370,6 +370,24 @@ const shouldMaskKey = (path, key) => { return true; } + // duplicate-table generated field ids. Each run seeds a fresh source table, so + // every source field (details.sourceFields[].id), source formula field + // (details.sourceFormulas[].id), and duplicated formula field + // (details.duplicate.duplicatedFormulaFields[].id) gets a new id between two + // runs of unchanged code (confirmed by the duplicate-table baseline A vs B + // diff). The semantic identity stays visible via the field names in those same + // arrays and the verifiedSamples expected values; only the opaque id strings + // are masked, mirroring the field-create createdFields[].id rule above. + if ( + isArrayIndex(path.at(-1)) && + ["sourceFields", "sourceFormulas", "duplicatedFormulaFields"].includes( + path.at(-2), + ) && + key === "id" + ) { + return true; + } + return false; }; From 4899d273b7092b0c25a181cf1280de93f3f42f72 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Sat, 20 Jun 2026 13:53:49 +0800 Subject: [PATCH 4/4] Update runner-migration tracker: 26/35 kinds, 36/55 cases Move formula-table, record-update-attachment, and duplicate-table to Migrated (field-add-lifecycle, record-mutation-lifecycle, and the new duplicate-lifecycle respectively) and drop them from Not migrated. Co-Authored-By: Claude --- tasks/runner-migration-tracker.md | 58 +++++++++++++++---------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/tasks/runner-migration-tracker.md b/tasks/runner-migration-tracker.md index ea2fa07..6ee505c 100644 --- a/tasks/runner-migration-tracker.md +++ b/tasks/runner-migration-tracker.md @@ -6,52 +6,52 @@ that uses it, because migrating a runner means re-verifying all of its cases. Status as of 2026-06-20 on `main`. -**Migrated: 23 / 35 runner kinds · 31 / 55 cases.** +**Migrated: 26 / 35 runner kinds · 36 / 55 cases.** ## Migrated (✅ on the driver) -| Runner kind | Driver / where | Cases | Verified | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| csv-import | `csv-import-lifecycle.ts` | 3 csv-import cases | ✅ v1+v2 pass (local) | -| field-delete | `field-delete-lifecycle.ts` | field-delete/mixed-10k-delete-19-fields | ✅ v1+v2 pass (local) | -| record-delete | `record-replay-lifecycle.ts` (no setup) | record-delete/delete-1k | ✅ v1+v2 pass (local) | -| record-delete-link | `table-link-lifecycle.ts` (single fixture) | record-delete/link-trash-1k | ✅ v1+v2 pass (local) | -| record-undo | `record-replay-lifecycle.ts` (delete setup) | record-undo/delete-1k | ✅ v1+v2 pass (local) | -| record-redo | `record-replay-lifecycle.ts` (delete+undo setup) | record-redo/delete-1k | ✅ v1+v2 pass (local) | -| table-delete | `table-lifecycle.ts` (sampled archive + restore-back) | table-delete/10k-20f | ✅ v1+v2 pass (local) | -| table-restore | `table-lifecycle.ts` (archive setup + sampled restore) | table-restore/10k-20f | ✅ v1+v2 pass (local) | -| table-delete-link | `table-link-lifecycle.ts` (sampled foreign-table delete) | table-delete/10k-20f-link-detach | ✅ v1+v2 pass (local) | -| table-restore-link | `table-link-lifecycle.ts` (sampled table restore) | table-restore/10k-20f-link-1k | ✅ v1+v2 pass (local) | -| selection-duplicate | `record-duplicate-lifecycle.ts` (stream block duplicate) | record-duplicate/grid-block-duplicate-1k | ✅ v1+v2 pass (local) | -| record-duplicate-single | `record-duplicate-lifecycle.ts` (sequential single dup) | record-duplicate/single-record-sequential-100 | ✅ v1+v2 pass (local) | -| record-update | `record-mutation-lifecycle.ts` (bulk update over seeded rows, record window + restore-or-delete) | record-update/mixed-1k-20fields-bulk-update | ✅ v1+v2 pass (local) | -| record-create | `record-mutation-lifecycle.ts` (bulk insert into empty seed, no window + delete-created-or-drop) | record-create/mixed-1k-20fields-bulk-create | ✅ v1+v2 pass (local) | -| record-reorder | `record-mutation-lifecycle.ts` (block reorder over seeded rows, record window + restore-order-or-drop) | record-reorder/10k-move-last-1k-to-front | ✅ v1+v2 pass (local) | -| field-convert | `field-convert-lifecycle.ts` (convert + readiness, keep-or-delete) | field-convert/10k-multi-select-to-text, field-convert/10k-text-to-formula | ✅ v1+v2 pass (local) | -| field-convert-link | `field-convert-lifecycle.ts` (link convert + readiness, delete host+foreign) | field-convert/10k-link-to-text, field-convert/10k-text-to-link | ✅ v1+v2 pass (local) | -| selection-clear | `record-mutation-lifecycle.ts` (clear-stream over seeded rows, no window, post-op verify, restore-or-delete) | selection-clear/flat-1k-20fields-cell-clear-stream | ✅ v1+v2 pass (local) | -| record-update-link | `record-mutation-lifecycle.ts` (bulk link-cell update over host + linked foreign fixture, no window) | record-update/1k-link-cells-bulk-update | ✅ v1+v2 pass (local) | -| conditional-lookup | `field-add-lifecycle.ts` (add a conditional lookup field over a seeded source + host pair, full-scan readiness, restore-or-delete) | lookup/conditional-10k | ✅ v1+v2 pass (local) | -| field-duplicate | `field-add-lifecycle.ts` (duplicate a lookup field over the conditional-lookup seed, backfill readiness, delete the copy or drop tables) | field-duplicate/conditional-lookup-10k | ✅ v1+v2 pass (local) | -| field-create | `field-add-lifecycle.ts` (add N fields over a seeded table, per-field routing + computed-backfill readiness, delete created fields or drop table) | field-create/10k-create-5-simple-fields, field-create/10k-create-5-formula-fields, field-create/mixed-10k-create-19-fields, field-create/single-select-1k-options | ✅ v1+v2 pass (local) | -| table-create | `table-create-lifecycle.ts` (seedless create-from-scratch: allocate a run accumulator, create N tables in one measured window with per-table routing, verify each, assert single engine, drop created tables on cleanup; no seed → no seedHash) | table-create/10x-20f-no-records, table-create/1x-20f-1k-records | ✅ v1+v2 pass (local) | +| Runner kind | Driver / where | Cases | Verified | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| csv-import | `csv-import-lifecycle.ts` | 3 csv-import cases | ✅ v1+v2 pass (local) | +| field-delete | `field-delete-lifecycle.ts` | field-delete/mixed-10k-delete-19-fields | ✅ v1+v2 pass (local) | +| record-delete | `record-replay-lifecycle.ts` (no setup) | record-delete/delete-1k | ✅ v1+v2 pass (local) | +| record-delete-link | `table-link-lifecycle.ts` (single fixture) | record-delete/link-trash-1k | ✅ v1+v2 pass (local) | +| record-undo | `record-replay-lifecycle.ts` (delete setup) | record-undo/delete-1k | ✅ v1+v2 pass (local) | +| record-redo | `record-replay-lifecycle.ts` (delete+undo setup) | record-redo/delete-1k | ✅ v1+v2 pass (local) | +| table-delete | `table-lifecycle.ts` (sampled archive + restore-back) | table-delete/10k-20f | ✅ v1+v2 pass (local) | +| table-restore | `table-lifecycle.ts` (archive setup + sampled restore) | table-restore/10k-20f | ✅ v1+v2 pass (local) | +| table-delete-link | `table-link-lifecycle.ts` (sampled foreign-table delete) | table-delete/10k-20f-link-detach | ✅ v1+v2 pass (local) | +| table-restore-link | `table-link-lifecycle.ts` (sampled table restore) | table-restore/10k-20f-link-1k | ✅ v1+v2 pass (local) | +| selection-duplicate | `record-duplicate-lifecycle.ts` (stream block duplicate) | record-duplicate/grid-block-duplicate-1k | ✅ v1+v2 pass (local) | +| record-duplicate-single | `record-duplicate-lifecycle.ts` (sequential single dup) | record-duplicate/single-record-sequential-100 | ✅ v1+v2 pass (local) | +| record-update | `record-mutation-lifecycle.ts` (bulk update over seeded rows, record window + restore-or-delete) | record-update/mixed-1k-20fields-bulk-update | ✅ v1+v2 pass (local) | +| record-create | `record-mutation-lifecycle.ts` (bulk insert into empty seed, no window + delete-created-or-drop) | record-create/mixed-1k-20fields-bulk-create | ✅ v1+v2 pass (local) | +| record-reorder | `record-mutation-lifecycle.ts` (block reorder over seeded rows, record window + restore-order-or-drop) | record-reorder/10k-move-last-1k-to-front | ✅ v1+v2 pass (local) | +| field-convert | `field-convert-lifecycle.ts` (convert + readiness, keep-or-delete) | field-convert/10k-multi-select-to-text, field-convert/10k-text-to-formula | ✅ v1+v2 pass (local) | +| field-convert-link | `field-convert-lifecycle.ts` (link convert + readiness, delete host+foreign) | field-convert/10k-link-to-text, field-convert/10k-text-to-link | ✅ v1+v2 pass (local) | +| selection-clear | `record-mutation-lifecycle.ts` (clear-stream over seeded rows, no window, post-op verify, restore-or-delete) | selection-clear/flat-1k-20fields-cell-clear-stream | ✅ v1+v2 pass (local) | +| record-update-link | `record-mutation-lifecycle.ts` (bulk link-cell update over host + linked foreign fixture, no window) | record-update/1k-link-cells-bulk-update | ✅ v1+v2 pass (local) | +| conditional-lookup | `field-add-lifecycle.ts` (add a conditional lookup field over a seeded source + host pair, full-scan readiness, restore-or-delete) | lookup/conditional-10k | ✅ v1+v2 pass (local) | +| field-duplicate | `field-add-lifecycle.ts` (duplicate a lookup field over the conditional-lookup seed, backfill readiness, delete the copy or drop tables) | field-duplicate/conditional-lookup-10k | ✅ v1+v2 pass (local) | +| field-create | `field-add-lifecycle.ts` (add N fields over a seeded table, per-field routing + computed-backfill readiness, delete created fields or drop table) | field-create/10k-create-5-simple-fields, field-create/10k-create-5-formula-fields, field-create/mixed-10k-create-19-fields, field-create/single-select-1k-options | ✅ v1+v2 pass (local) | +| table-create | `table-create-lifecycle.ts` (seedless create-from-scratch: allocate a run accumulator, create N tables in one measured window with per-table routing, verify each, assert single engine, drop created tables on cleanup; no seed → no seedHash) | table-create/10x-20f-no-records, table-create/1x-20f-1k-records | ✅ v1+v2 pass (local) | +| formula-table | `field-add-lifecycle.ts` (seed a numeric source table, wait for source readability, create the formula field(s) and wait for the computed backfill + full scan, restore the seed by deleting the added formula fields or drop the table) | formula/10k-calc, formula/10k-5-concurrent | ✅ v1+v2 pass (local) | +| record-update-attachment | `record-mutation-lifecycle.ts` (execute-only attachment upload + warmup + p95-sampled bulk attachment-cell update over seeded rows, no window, clear-cells-or-delete cleanup) | record-update/attachment-insert-100 | ✅ v1+v2 pass (local) | +| duplicate-table | `duplicate-lifecycle.ts` (NEW first member: seed/restore the source table, measured duplicate request + copy-readiness full scan, drop the created copy and the source unless it is a reusable cached seed) | duplicate-table/10k-20f, duplicate-table/10k-25f-5formula | ✅ v1+v2 pass (local) | ## Not migrated (⬜ legacy `*.runner.ts`) | Runner kind | # | Cases | | ------------------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | duplicate-base | 3 | duplicate-base/10k-3tables-link-2workflow, duplicate-base/10k-3tables-link-2workflow-stream, export-base/10k-3tables-link-2workflow-stream | -| duplicate-table | 2 | duplicate-table/10k-20f, duplicate-table/10k-25f-5formula | | field-update | 1 | field-update/v2-only-10k-select-option-rename-computed-cascade | | form-submit | 1 | form-submit/sequential-200 | -| formula-table | 2 | formula/10k-calc, formula/10k-5-concurrent | | http-endpoint | 1 | smoke/auth-user | | import-base | 3 | import-base/v2-only-simple-1x1k-table-stream, import-base/v2-only-complex-3x10k-3tables-2workflow-stream, import-base/v2-only-user-t2377-tea-stream | | link-computed-propagation | 2 | lookup/dual-link-computed-first-link-4k, lookup/dual-link-computed-repoint-2k | | lookup-search-index | 2 | search/search-index-off-10k-20search-fields, search/search-index-on-10k-20search-fields | | record-paste | 4 | record-paste/flat-10k-4fields-copy-paste, record-paste/flat-10k-20fields-copy-paste, record-paste/mixed-10k-20fields-complex-copy-paste, selection-paste/10k-expand-rows-and-fields-stream | | record-read | 2 | record-read/10k-50fields-10x1k-pages, record-read/10k-50fields-filter-sort-groupby-overhead | -| record-update-attachment | 1 | record-update/attachment-insert-100 | ## How migration proceeds