From 000d3ad8b85483edd3e5f540feae328e4df331a1 Mon Sep 17 00:00:00 2001 From: HynLcc Date: Fri, 19 Jun 2026 23:06:40 +0800 Subject: [PATCH] Migrate record-update-link onto the record-mutation lifecycle driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit record-update-link already had the record-mutation family's exact execute shape — prepare -> seedReady -> trace-wrapped bulk update -> folded post-op verify, restore-or-delete cleanup, phases [prepare, , seedReady] — identical to record-update. It now rides record-mutation-lifecycle instead of hand-rolling that protocol. It is the first member whose fixture spans more than one table (a host + linked foreign pair). The driver already treats the fixture opaquely, so this required no driver code change — only relaxing the scope note's "single seeded table" assumption. assertSeedReady stays defined (seed and execute both assert the seed link permutation), so seed and execute route through one shared spec, like record-update/create. Mapping details: - runMeasuredOperation resolves foreign ids (unmeasured), then runs the trace-wrapped updateRecords + routing assertion + sample/full-scan verify, bundled into one primary measurement. - cleanup re-resolves foreignIdByTitle (cleanup is unmeasured) to restore the seed permutation, or drops both tables if restore fails. - buildResult already matched the driver arg shape; the spec delegates to it unchanged (dropping the unused windowId). Verification (local, execute, v1+v2): - pnpm check green. - baseline A vs B (record-update-link): 2/2 pass, zero new diff masks. - G1 baseline vs candidate: 10/10 pass — record-update-link + record-update/ record-create/record-reorder/selection-clear regression (shared driver scope-note touched). - negative: phases[0].name / details.operation / routing.routeMatched / fullScan.scannedRecords / seed.ready.expectedTitle perturbations fail; masked metric value still passes. - candidate artifacts: all 10 result=pass; record-update-link routeMatched=true, actualXTeableV2 matches engine, fullScan.scannedRecords==rowCount(1000), updatedRecords==1000, bulkUpdate1kLinkCellsMs far below max. Tracker: 19/35 runner kinds, 23/55 cases. Co-Authored-By: Claude --- .../runners/record-mutation-lifecycle.ts | 17 +- .../runners/record-update-link.runner.ts | 264 +++++++++--------- tasks/runner-migration-tracker.md | 4 +- 3 files changed, 147 insertions(+), 138 deletions(-) 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