From c5fe80ad58ba8ce1efde03d3c8d672ab121dd10b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 12:19:43 +0100 Subject: [PATCH] fix: make qa config apply retries idempotent --- .../qa-lab/src/suite-runtime-gateway.test.ts | 51 +++++++++++++++++++ .../qa-lab/src/suite-runtime-gateway.ts | 33 ++++++++++++ 2 files changed, 84 insertions(+) diff --git a/extensions/qa-lab/src/suite-runtime-gateway.test.ts b/extensions/qa-lab/src/suite-runtime-gateway.test.ts index c530d265217..985be291d56 100644 --- a/extensions/qa-lab/src/suite-runtime-gateway.test.ts +++ b/extensions/qa-lab/src/suite-runtime-gateway.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { getGatewayRetryAfterMs, + isConfigApplyNoopForSnapshot, isConfigHashConflict, isConfigPatchNoopForSnapshot, } from "./suite-runtime-gateway.js"; @@ -62,4 +63,54 @@ describe("qa suite gateway helpers", () => { ), ).toBe(false); }); + + it("detects full config applies that only differ by gateway-written metadata", () => { + const config = { + gateway: { + controlUi: { + allowedOrigins: ["http://127.0.0.1:5173"], + }, + }, + meta: { + updatedAt: "2026-04-25T10:00:00.000Z", + }, + }; + + expect( + isConfigApplyNoopForSnapshot( + config, + JSON.stringify({ + gateway: { + controlUi: { + allowedOrigins: ["http://127.0.0.1:5173"], + }, + }, + }), + ), + ).toBe(true); + }); + + it("keeps changed full config applies eligible for the gateway", () => { + expect( + isConfigApplyNoopForSnapshot( + { + gateway: { + controlUi: { + allowedOrigins: ["http://127.0.0.1:5173"], + }, + }, + meta: { + updatedAt: "2026-04-25T10:00:00.000Z", + }, + }, + JSON.stringify({ + gateway: { + controlUi: { + allowedOrigins: ["http://127.0.0.1:5174"], + }, + }, + }), + ), + ).toBe(false); + }); }); diff --git a/extensions/qa-lab/src/suite-runtime-gateway.ts b/extensions/qa-lab/src/suite-runtime-gateway.ts index e36c6c2d4b8..b978451997a 100644 --- a/extensions/qa-lab/src/suite-runtime-gateway.ts +++ b/extensions/qa-lab/src/suite-runtime-gateway.ts @@ -178,6 +178,32 @@ function areJsonValuesEqual(left: unknown, right: unknown): boolean { return false; } +function withoutQaConfigApplyVolatileFields( + config: Record, +): Record { + const comparable = structuredClone(config); + // config.apply updates root metadata on write. Retries should not turn a + // completed apply into a metadata-only write/restart loop. + delete comparable.meta; + return comparable; +} + +function isConfigApplyNoopForSnapshot(config: Record, raw: string): boolean { + let nextConfig: unknown; + try { + nextConfig = JSON.parse(raw); + } catch { + return false; + } + if (!isPlainObject(nextConfig)) { + return false; + } + return areJsonValuesEqual( + withoutQaConfigApplyVolatileFields(config), + withoutQaConfigApplyVolatileFields(nextConfig), + ); +} + function isConfigPatchNoopForSnapshot(config: Record, raw: string): boolean { let patch: unknown; try { @@ -233,6 +259,12 @@ async function runConfigMutation(params: { // control-plane write budget and making later capability checks flaky. return { ok: true, noop: true }; } + if ( + params.action === "config.apply" && + isConfigApplyNoopForSnapshot(snapshot.config, params.raw) + ) { + return { ok: true, noop: true }; + } try { const result = await params.env.gateway.call( params.action, @@ -327,6 +359,7 @@ export { fetchJson, formatGatewayPrimaryErrorText, getGatewayRetryAfterMs, + isConfigApplyNoopForSnapshot, isConfigPatchNoopForSnapshot, isConfigHashConflict, isGatewayRestartRace,