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
55 changes: 35 additions & 20 deletions framework/runners/record-mutation-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,21 @@ 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. Two runner kinds now ride it —
// record-update (bulk update over seeded rows, runs inside a record window)
// and record-create (bulk insert into an empty seeded table, 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
// restore-or-delete the reusable fixture. Four 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
// 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:
// 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,
// the seed-ready assertion, the bundled measured window (operation + routing +
// verification), the result assembly, and the restore-or-delete cleanup.
// the (optional) seed-ready assertion, the bundled measured window (operation +
// routing + verification), the result assembly, and the restore-or-delete
// cleanup. assertSeedReady is optional: selection-clear confirms seed readiness
// inside prepareFixture and emits only a post-op verify phase, so it omits the
// 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
Expand Down Expand Up @@ -70,8 +75,12 @@ export type RecordMutationLifecycleSpec<
perfCase: PerfCase;
context: PerfRunContext;
}) => Promise<TFixture>;
// Assert the seeded state is readable before the measured operation runs.
assertSeedReady: (args: {
// Assert the seeded state is readable before the measured operation runs,
// emitted as the `seedReady` phase. Optional: a family member whose seed
// readiness is confirmed inside prepareFixture (and whose only post-op check
// is a separate verify phase) omits it, so no `seedReady` phase is produced —
// selection-clear rides the driver this way.
assertSeedReady?: (args: {
baseId: string;
fixture: TFixture;
config: TConfig;
Expand Down Expand Up @@ -121,13 +130,16 @@ export const seedRecordMutationLifecycle = async <
const prepareMeasurement = await measureAsync("prepare", () =>
spec.prepareFixture({ baseId, tableName, config, perfCase, context }),
);
const seedReadyMeasurement = await measureAsync("seedReady", () =>
spec.assertSeedReady({
baseId,
fixture: prepareMeasurement.result,
config,
}),
);
const assertSeedReady = spec.assertSeedReady;
const seedReadyMeasurement = assertSeedReady
? await measureAsync("seedReady", () =>
assertSeedReady({
baseId,
fixture: prepareMeasurement.result,
config,
}),
)
: undefined;

return spec.buildResult({
config,
Expand Down Expand Up @@ -161,9 +173,12 @@ export const runRecordMutationLifecycle = async <
spec.prepareFixture({ baseId, tableName, config, perfCase, context }),
);
fixture = prepareMeasurement.result;
seedReadyMeasurement = await measureAsync("seedReady", () =>
spec.assertSeedReady({ baseId, fixture: fixture as TFixture, config }),
);
const assertSeedReady = spec.assertSeedReady;
if (assertSeedReady) {
seedReadyMeasurement = await measureAsync("seedReady", () =>
assertSeedReady({ baseId, fixture: fixture as TFixture, config }),
);
}

try {
const invokeMeasured = async () => {
Expand Down
221 changes: 126 additions & 95 deletions framework/runners/selection-clear.runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,11 @@ import type {
PerfRunResult,
SelectionClearCaseConfig,
} from "../types";
import { PerfRunDiagnosticError } from "../types";

type Measurement<T> = {
name: string;
durationMs: number;
result: T;
};
import {
runRecordMutationLifecycle,
type RecordMutationLifecycleSpec,
} from "./record-mutation-lifecycle";
import { type Measurement } from "./record-undo-redo.shared";

type NamedField = {
id: string;
Expand Down Expand Up @@ -806,112 +804,145 @@ const buildSelectionClearResult = ({
},
});

export const runSelectionClearCase = async (
type SelectionClearPrimaryResult = {
clear: Awaited<ReturnType<typeof clearAllCells>>;
verify: Measurement<Awaited<ReturnType<typeof assertCellsCleared>>>;
};

// The single measured operation: trace-wrapped clear-stream -> routing
// assertion (inside clearAllCells) -> post-clear full-scan verification. The
// clear duration is the primary metric (clear1kMs); verify is bundled into the
// primary result so buildResult can still emit it as the separate `verify`
// phase the legacy artifact had. selection-clear has no record window, so the
// driver invokes this directly.
const runSelectionClearMeasuredOperation = async (
perfCase: PerfCase,
context: PerfRunContext,
): Promise<PerfRunResult> => {
const config = perfCase.config as SelectionClearCaseConfig;
const baseId = globalThis.testConfig.baseId;
const tableName = `${config.tableNamePrefix}-${Date.now()}`;
let prepareMeasurement: Measurement<ClearFixture> | undefined;
let restoreMeasurement:
| Measurement<Awaited<ReturnType<typeof restoreClearedCells>>>
| undefined;

try {
prepareMeasurement = await measureAsync("prepare", () =>
prepareClearFixture(baseId, tableName, config, perfCase),
);
const fixture = prepareMeasurement.result;
let clearMeasurement:
| Measurement<Awaited<ReturnType<typeof clearAllCells>>>
| undefined;
let verifyMeasurement:
| Measurement<Awaited<ReturnType<typeof assertCellsCleared>>>
| undefined;
config: SelectionClearCaseConfig,
fixture: ClearFixture,
): Promise<Measurement<SelectionClearPrimaryResult>> => {
const clearMeasurement = await withPerfTraceStep(
context,
perfCase,
"clear",
() =>
measureAsync("clear", () => clearAllCells(fixture, perfCase, context)),
);
const verifyMeasurement = await measureAsync("verify", () =>
assertCellsCleared(fixture, config),
);
return {
name: clearMeasurement.name,
durationMs: clearMeasurement.durationMs,
result: {
clear: clearMeasurement.result,
verify: verifyMeasurement,
},
};
};

// The measured clear empties the reusable seed's cells, so a shared
// (non-isolated) execute DB must be restored to the seed values — or the table
// dropped if restore fails — before the next run reuses it. Isolated CI execute
// DBs are discarded after the job, so no cleanup is needed there.
const cleanupSelectionClearFixture = async ({
baseId,
fixture,
config,
}: {
baseId: string;
fixture: ClearFixture | undefined;
config: SelectionClearCaseConfig;
}) => {
if (!fixture || isExecuteDbIsolated()) {
return;
}
if (fixture.reusableSeed) {
try {
clearMeasurement = await withPerfTraceStep(
context,
perfCase,
"clear",
() =>
measureAsync("clear", () =>
clearAllCells(fixture, perfCase, context),
),
const restoreMeasurement = await measureAsync("restoreSeed", () =>
restoreClearedCells(fixture, config),
);

verifyMeasurement = await measureAsync("verify", () =>
assertCellsCleared(fixture, config),
console.log(
`[perf-lab] restored selection clear seed table=${fixture.tableId} durationMs=${Math.round(
restoreMeasurement.durationMs,
)}`,
);
} catch (error) {
const diagnosticResult = buildSelectionClearResult({
config,
fixture,
prepareMeasurement,
clearMeasurement,
verifyMeasurement,
console.warn(
`Failed to restore cached selection clear seed ${fixture.tableId}; deleting it`,
error,
});

throw new PerfRunDiagnosticError(
error instanceof Error ? error.message : String(error),
diagnosticResult,
);
}

return buildSelectionClearResult({
config,
fixture,
prepareMeasurement,
clearMeasurement,
verifyMeasurement,
});
} finally {
if (prepareMeasurement?.result.reusableSeed && !isExecuteDbIsolated()) {
try {
restoreMeasurement = await measureAsync("restoreSeed", () =>
restoreClearedCells(prepareMeasurement!.result, config),
);
} catch (error) {
console.warn(
`Failed to restore cached selection clear seed ${prepareMeasurement.result.tableId}; deleting it`,
error,
);
try {
await permanentDeleteTable(baseId, prepareMeasurement.result.tableId);
} catch (cleanupError) {
console.warn(
`Failed to cleanup perf table ${prepareMeasurement.result.tableId}`,
cleanupError,
);
}
}
} else if (
prepareMeasurement?.result.tableId &&
!prepareMeasurement.result.reusableSeed &&
!isExecuteDbIsolated()
) {
try {
await permanentDeleteTable(baseId, prepareMeasurement.result.tableId);
} catch (error) {
await permanentDeleteTable(baseId, fixture.tableId);
} catch (cleanupError) {
console.warn(
`Failed to cleanup perf table ${prepareMeasurement.result.tableId}`,
error,
`Failed to cleanup perf table ${fixture.tableId}`,
cleanupError,
);
}
}

if (restoreMeasurement) {
console.log(
`[perf-lab] restored selection clear seed table=${prepareMeasurement?.result.tableId} durationMs=${Math.round(
restoreMeasurement.durationMs,
)}`,
);
} else if (fixture.tableId) {
try {
await permanentDeleteTable(baseId, fixture.tableId);
} catch (error) {
console.warn(`Failed to cleanup perf table ${fixture.tableId}`, error);
}
}
};

// selection-clear rides the record-mutation lifecycle: single seeded table, one
// measured clear, post-op verify, restore-or-delete. It omits assertSeedReady
// (seed readiness is confirmed inside prepareClearFixture, which re-verifies a
// cached seed before reuse) so the driver emits no seedReady phase, preserving
// the legacy [prepare, clear, verify] artifact. No record window.
const selectionClearLifecycleSpec: RecordMutationLifecycleSpec<
SelectionClearCaseConfig,
ClearFixture,
never,
SelectionClearPrimaryResult
> = {
prepareFixture: ({ baseId, tableName, config, perfCase }) =>
prepareClearFixture(baseId, tableName, config, perfCase),
runMeasuredOperation: ({ perfCase, context, config, fixture }) =>
runSelectionClearMeasuredOperation(perfCase, context, config, fixture),
// Adapter: the driver hands back one primary measurement; split it back into
// the legacy clear + verify measurements so buildSelectionClearResult — and
// therefore the artifact shape — is unchanged.
buildResult: ({
config,
fixture,
prepareMeasurement,
primaryMeasurement,
error,
}) =>
buildSelectionClearResult({
config,
fixture,
prepareMeasurement,
clearMeasurement: primaryMeasurement
? {
name: primaryMeasurement.name,
durationMs: primaryMeasurement.durationMs,
result: primaryMeasurement.result.clear,
}
: undefined,
verifyMeasurement: primaryMeasurement?.result.verify,
error,
}),
cleanup: cleanupSelectionClearFixture,
};

export const runSelectionClearCase = async (
perfCase: PerfCase,
context: PerfRunContext,
): Promise<PerfRunResult> =>
runRecordMutationLifecycle(perfCase, context, selectionClearLifecycleSpec);

// Seed mode stays bespoke (not on seedRecordMutationLifecycle): unlike the
// execute path, the seed artifact intentionally carries a `seedReady` phase
// (the assertCellsRestored full scan), which is exactly the seedReady hook the
// execute path omits. Routing both modes through one shared spec would force
// that asymmetry into the driver, so seed keeps its own thin orchestration here.
export const seedSelectionClearCase = async (
perfCase: PerfCase,
_context: PerfRunContext,
Expand Down
Loading