diff --git a/framework/runners/record-mutation-lifecycle.ts b/framework/runners/record-mutation-lifecycle.ts index fd0fb75..7de13fb 100644 --- a/framework/runners/record-mutation-lifecycle.ts +++ b/framework/runners/record-mutation-lifecycle.ts @@ -9,13 +9,14 @@ import { // The lifecycle skeleton shared by the record-mutation family: seed a record // table, run one measured bulk mutation, verify the final state, then -// restore-or-delete the reusable fixture. Four runner kinds now ride it — +// restore-or-delete the reusable fixture. Five runner kinds now ride it — // record-update (bulk update over seeded rows, runs inside a record window), // record-create (bulk insert into an empty seeded table, no window), -// record-reorder (block reorder over seeded rows, record window), and +// record-reorder (block reorder over seeded rows, record window), // selection-clear (clear-stream over seeded rows, no window, no seedReady -// phase) — so the shared shape is a proven seam, not a guess. The driver owns -// generic protocol only: +// phase), and record-update-link (bulk link-cell update over a host + linked +// foreign fixture, no window) — so the shared shape is a proven seam, not a +// guess. The driver owns generic protocol only: // prepare(seed) -> [seedReady?] -> [window?] measured op -> build result // (twice: diagnostic catch + success) -> finally cleanup. // Each runner declares the case semantics it varies: the seed-cache fixture, @@ -26,9 +27,11 @@ import { // hook and the driver produces no seedReady phase. // // Scope note: this is record-mutation-family-shaped, not a universal runner -// driver. It still assumes a single seeded table, one primary measured -// operation, and a restore-or-delete fixture. A broader abstraction should wait -// for a family that breaks one of those assumptions. +// driver. The fixture is opaque to the driver, so it may span more than one +// table — record-update-link seeds a host + linked foreign pair and rides this +// unchanged. It still assumes one primary measured operation against a reusable +// fixture cleaned up by restore-or-delete. A broader abstraction should wait +// for a family that breaks one of those remaining assumptions. export type RecordMutationLifecycleConfig = { tableNamePrefix: string }; diff --git a/framework/runners/record-update-link.runner.ts b/framework/runners/record-update-link.runner.ts index 189ba9a..55c695d 100644 --- a/framework/runners/record-update-link.runner.ts +++ b/framework/runners/record-update-link.runner.ts @@ -29,7 +29,12 @@ import type { PerfRunResult, RecordUpdateLinkCaseConfig, } from "../types"; -import { PerfRunDiagnosticError } from "../types"; +import { + runRecordMutationLifecycle, + seedRecordMutationLifecycle, + type RecordMutationLifecycleSpec, +} from "./record-mutation-lifecycle"; +import { type Measurement } from "./record-undo-redo.shared"; import { expectedForeignTitle, fetchForeignIdByTitle, @@ -44,12 +49,6 @@ const RECORD_UPDATE_LINK_METADATA_PREFIX = "perf-lab-record-update-link:"; type Phase = "seed" | "updated"; -type Measurement = { - name: string; - durationMs: number; - result: T; -}; - type NamedField = { id: string; name: string; type?: string }; type SeededRecord = { @@ -711,143 +710,150 @@ const buildResult = ({ }, }); -export const runRecordUpdateLinkCase = async ( +// The single measured operation: resolve foreign ids (unmeasured setup) -> +// trace-wrapped bulk link-cell update -> routing assertion -> post-update +// sample + full-scan verification, bundled into one primary measurement whose +// duration is the primary metric. record-update-link has no record window, so +// the driver invokes this directly (no withRecordWindowId). +const runLinkUpdateMeasuredOperation = async ( perfCase: PerfCase, context: PerfRunContext, -): Promise => { - const config = perfCase.config as RecordUpdateLinkCaseConfig; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.tableNamePrefix}-${Date.now()}`; - let prepareMeasurement: Measurement | undefined; - let seedReadyMeasurement: Measurement<{ checkedRecords: number }> | undefined; - let fixture: RecordUpdateLinkFixture | undefined; - let foreignIdByTitle: Map | undefined; - - try { - prepareMeasurement = await measureAsync("prepare", () => - prepareLinkFixture(baseId, tableName, config, perfCase), - ); - fixture = prepareMeasurement.result; - seedReadyMeasurement = await measureAsync("seedReady", () => - assertLinkSamples(fixture!, config, "seed"), - ); - // Execute setup (not measured): resolve foreign titles -> record ids. - foreignIdByTitle = await fetchForeignIdByTitle( - fixture.foreignTableId, - fixture.foreignKeyFieldId, - config.foreignTable.rowCount, - ); + config: RecordUpdateLinkCaseConfig, + fixture: RecordUpdateLinkFixture, +): Promise> => { + // Execute setup (not measured): resolve foreign titles -> record ids. + const foreignIdByTitle = await fetchForeignIdByTitle( + fixture.foreignTableId, + fixture.foreignKeyFieldId, + config.foreignTable.rowCount, + ); + const updateMeasurement = await withPerfTraceStep( + context, + perfCase, + config.threshold.metric, + () => + measureAsync(config.threshold.metric, () => + updateAllLinks(fixture, config, "updated", foreignIdByTitle), + ), + ); + const routing = assertEngineRouting( + context, + updateMeasurement.result.responseHeaders, + { operation: "updateRecords" }, + ); + const verifyMeasurement = await measureAsync("verifyUpdated", () => + assertLinkSamples(fixture, config, "updated"), + ); + const fullScan = await assertLinkFullScan(fixture, config, "updated"); + return { + ...updateMeasurement, + result: { + updateRequestMs: updateMeasurement.durationMs, + requestedRecords: updateMeasurement.result.requestedRecords, + updatedRecords: updateMeasurement.result.updatedRecords, + responseHeaders: updateMeasurement.result.responseHeaders, + routing, + verified: { checkedRecords: verifyMeasurement.result.checkedRecords }, + verifyUpdatedMs: verifyMeasurement.durationMs, + fullScan, + }, + }; +}; - let primaryMeasurement: Measurement | undefined; +// Class C cleanup: the measured update repoints the reusable seed's link cells +// to the update permutation, so a shared (non-isolated) execute DB must be +// restored to the seed permutation — or both fixture tables dropped if restore +// fails — before the next run reuses it. foreignIdByTitle is re-resolved here +// (cleanup is unmeasured) instead of being threaded from the measured op. +// Isolated CI execute DBs are discarded after the job, so cleanup is skipped. +const cleanupRecordUpdateLinkFixture = async ({ + baseId, + fixture, + config, +}: { + baseId: string; + fixture: RecordUpdateLinkFixture | undefined; + config: RecordUpdateLinkCaseConfig; +}) => { + if (!fixture || isExecuteDbIsolated()) { + return; + } + if (fixture.reusableSeed) { + let restored = false; try { - const updateMeasurement = await withPerfTraceStep( - context, - perfCase, - config.threshold.metric, - () => - measureAsync(config.threshold.metric, () => - updateAllLinks(fixture!, config, "updated", foreignIdByTitle!), - ), + const foreignIdByTitle = await fetchForeignIdByTitle( + fixture.foreignTableId, + fixture.foreignKeyFieldId, + config.foreignTable.rowCount, ); - const routing = assertEngineRouting( - context, - updateMeasurement.result.responseHeaders, - { operation: "updateRecords" }, - ); - const verifyMeasurement = await measureAsync("verifyUpdated", () => - assertLinkSamples(fixture!, config, "updated"), - ); - const fullScan = await assertLinkFullScan(fixture!, config, "updated"); - primaryMeasurement = { - ...updateMeasurement, - result: { - updateRequestMs: updateMeasurement.durationMs, - requestedRecords: updateMeasurement.result.requestedRecords, - updatedRecords: updateMeasurement.result.updatedRecords, - responseHeaders: updateMeasurement.result.responseHeaders, - routing, - verified: { checkedRecords: verifyMeasurement.result.checkedRecords }, - verifyUpdatedMs: verifyMeasurement.durationMs, - fullScan, - }, - }; + await updateAllLinks(fixture, config, "seed", foreignIdByTitle); + await assertLinkSamples(fixture, config, "seed"); + restored = true; } catch (error) { - throw new PerfRunDiagnosticError( - error instanceof Error ? error.message : String(error), - buildResult({ - config, - fixture, - prepareMeasurement, - seedReadyMeasurement, - primaryMeasurement, - error, - }), + console.warn( + `Failed to restore cached link seed ${fixture.tableId}; deleting it`, + error, ); } + if (restored) { + return; + } + } + for (const tableId of [fixture.tableId, fixture.foreignTableId]) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + console.warn(`Failed to cleanup link table ${tableId}`, error); + } + } +}; - return buildResult({ +// record-update-link rides the record-mutation lifecycle: seed a host + linked +// foreign fixture, run one measured bulk link-cell update inside the family's +// prepare -> seedReady -> measured op -> restore-or-delete skeleton. It is the +// first member whose fixture spans more than one table; the driver treats the +// fixture opaquely, so no driver code changes — only the scope note. No window. +const recordUpdateLinkLifecycleSpec: RecordMutationLifecycleSpec< + RecordUpdateLinkCaseConfig, + RecordUpdateLinkFixture, + Awaited>, + LinkUpdatePrimaryResult +> = { + prepareFixture: ({ baseId, tableName, config, perfCase }) => + prepareLinkFixture(baseId, tableName, config, perfCase), + assertSeedReady: ({ fixture, config }) => + assertLinkSamples(fixture, config, "seed"), + runMeasuredOperation: ({ perfCase, context, config, fixture }) => + runLinkUpdateMeasuredOperation(perfCase, context, config, fixture), + // buildResult already matches the driver arg shape; drop the unused windowId + // (no record window) and delegate to the existing assembler unchanged. + buildResult: ({ + config, + fixture, + prepareMeasurement, + seedReadyMeasurement, + primaryMeasurement, + error, + }) => + buildResult({ config, fixture, prepareMeasurement, seedReadyMeasurement, primaryMeasurement, - }); - } finally { - // Class C cleanup: restore link cells to the seed permutation so the - // cached fixture is seed-ready for the next run; if restore fails, delete - // both fixture tables. Isolated CI execute DBs are discarded, skip all. - if (!isExecuteDbIsolated()) { - if (fixture?.reusableSeed && foreignIdByTitle) { - let restored = false; - try { - await updateAllLinks(fixture, config, "seed", foreignIdByTitle); - await assertLinkSamples(fixture, config, "seed"); - restored = true; - } catch (error) { - console.warn( - `Failed to restore cached link seed ${fixture.tableId}; deleting it`, - error, - ); - } - if (!restored) { - for (const tableId of [fixture.tableId, fixture.foreignTableId]) { - try { - await permanentDeleteTable(baseId, tableId); - } catch (error) { - console.warn(`Failed to cleanup link table ${tableId}`, error); - } - } - } - } else if (fixture && !fixture.reusableSeed) { - for (const tableId of [fixture.tableId, fixture.foreignTableId]) { - try { - await permanentDeleteTable(baseId, tableId); - } catch (error) { - console.warn(`Failed to cleanup link table ${tableId}`, error); - } - } - } - } - } + error, + }), + cleanup: cleanupRecordUpdateLinkFixture, }; +export const runRecordUpdateLinkCase = async ( + perfCase: PerfCase, + context: PerfRunContext, +): Promise => + runRecordMutationLifecycle(perfCase, context, recordUpdateLinkLifecycleSpec); + export const seedRecordUpdateLinkCase = async ( perfCase: PerfCase, - _context: PerfRunContext, -): Promise => { - const config = perfCase.config as RecordUpdateLinkCaseConfig; - const baseId = globalThis.testConfig.baseId; - const tableName = `${config.tableNamePrefix}-seed-${Date.now()}`; - const prepareMeasurement = await measureAsync("prepare", () => - prepareLinkFixture(baseId, tableName, config, perfCase), - ); - const seedReadyMeasurement = await measureAsync("seedReady", () => - assertLinkSamples(prepareMeasurement.result, config, "seed"), - ); - return buildResult({ - config, - fixture: prepareMeasurement.result, - prepareMeasurement, - seedReadyMeasurement, - }); -}; + context: PerfRunContext, +): Promise => + seedRecordMutationLifecycle(perfCase, context, recordUpdateLinkLifecycleSpec); diff --git a/tasks/runner-migration-tracker.md b/tasks/runner-migration-tracker.md index 7a82b26..890af50 100644 --- a/tasks/runner-migration-tracker.md +++ b/tasks/runner-migration-tracker.md @@ -6,7 +6,7 @@ that uses it, because migrating a runner means re-verifying all of its cases. Status as of 2026-06-19 on `main`. -**Migrated: 18 / 35 runner kinds · 22 / 55 cases.** +**Migrated: 19 / 35 runner kinds · 23 / 55 cases.** ## Migrated (✅ on the driver) @@ -30,6 +30,7 @@ Status as of 2026-06-19 on `main`. | 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) | ## Not migrated (⬜ legacy `*.runner.ts`) @@ -50,7 +51,6 @@ Status as of 2026-06-19 on `main`. | 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 | -| record-update-link | 1 | record-update/1k-link-cells-bulk-update | | table-create | 2 | table-create/10x-20f-no-records, table-create/1x-20f-1k-records | ## How migration proceeds