Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions framework/runners/record-mutation-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 };

Expand Down
264 changes: 135 additions & 129 deletions framework/runners/record-update-link.runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -44,12 +49,6 @@ const RECORD_UPDATE_LINK_METADATA_PREFIX = "perf-lab-record-update-link:";

type Phase = "seed" | "updated";

type Measurement<T> = {
name: string;
durationMs: number;
result: T;
};

type NamedField = { id: string; name: string; type?: string };

type SeededRecord = {
Expand Down Expand Up @@ -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<PerfRunResult> => {
const config = perfCase.config as RecordUpdateLinkCaseConfig;
const baseId = globalThis.testConfig.baseId;
const tableName = `${config.tableNamePrefix}-${Date.now()}`;
let prepareMeasurement: Measurement<RecordUpdateLinkFixture> | undefined;
let seedReadyMeasurement: Measurement<{ checkedRecords: number }> | undefined;
let fixture: RecordUpdateLinkFixture | undefined;
let foreignIdByTitle: Map<string, string> | 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<Measurement<LinkUpdatePrimaryResult>> => {
// 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<LinkUpdatePrimaryResult> | 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<ReturnType<typeof assertLinkSamples>>,
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<PerfRunResult> =>
runRecordMutationLifecycle(perfCase, context, recordUpdateLinkLifecycleSpec);

export const seedRecordUpdateLinkCase = async (
perfCase: PerfCase,
_context: PerfRunContext,
): Promise<PerfRunResult> => {
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<PerfRunResult> =>
seedRecordMutationLifecycle(perfCase, context, recordUpdateLinkLifecycleSpec);
4 changes: 2 additions & 2 deletions tasks/runner-migration-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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`)

Expand All @@ -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
Expand Down