Files
triggerdotdev--trigger.dev/apps/webapp/test/runOpsSplitReadGate.test.ts
T
Daniel Sutton d59743bd35 fix(webapp,run-ops-database): keep run-ops batch items co-resident with their batch (#4178)
## Summary

Three fixes to the run-ops database split (the Cloud-only mode where
run-lifecycle rows live on a dedicated Postgres). All are inert in the
default single-database deployment.

The main fix: on the batch trigger paths, a parentless batch's item runs
chose their physical store from a fresh per-org mint-flag read at
processing time, so flipping an org's flag mid-batch could land an item
in a different store than its batch, breaking the `TaskRun.batchId`
foreign key (or silently orphaning the item). The other two harden the
split's safety nets: the schema-parity test now actually compares
columns, and the read fan-out gate now signals when it has been silently
disabled.

## Batch item residency

`RunEngineBatchTriggerService` (api.v2) and the BatchQueue item callback
(api.v3) now anchor each item's id mint on the batch's own friendlyId,
mirroring the already-safe `BatchTriggerV3Service`. Residency is a pure
id-shape check, so an item can no longer diverge from its batch across a
mid-batch flag flip. The pre-failed-run fallback is anchored the same
way (it also sets `batchId`), and the shared mint branch is consolidated
into one helper so every mint path stays in lockstep. No new database
queries; single-database mode is unchanged (a cuid-shaped batch
friendlyId yields a cuid item).

## Schema parity test

The parity test previously read only the dedicated schema and matched
model headers with regexes, so it never compared columns and could not
catch a run-subgraph column that diverged between the two physical
schemas. It now parses both schemas and asserts bidirectional
scalar-column parity (type, nullability, array-ness, default) across the
run-subgraph models, and fails on any field line it can't parse. Scoped
to the run-subgraph models so unrelated control-plane edits don't break
it.

## Read fan-out signal

The split read fan-out gate is decided by the object identity of the NEW
vs control-plane clients. It now warns when both run-ops URLs are set
but the NEW client isn't a distinct instance (fan-out silently off), and
a new test exercises the real topology-into-gate wiring so a future
refactor that aliases the clients can't disable fan-out unnoticed.

## Verification

New unit and glue tests cover all three changes; the DB-backed
residency, store-routing, and topology suites pass against real
Postgres; `typecheck` is clean for both packages.
2026-07-07 14:17:23 +01:00

168 lines
5.7 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { computeRunOpsSplitReadEnabled } from "~/v3/runOpsMigration/runOpsSplitReadGate";
// Distinct sentinel objects standing in for the prisma client singletons.
const cpWriter = { __tag: "cp-writer" };
const cpReplica = { __tag: "cp-replica" };
const dedicatedNew = { __tag: "dedicated-new" };
describe("computeRunOpsSplitReadEnabled", () => {
it("enables split when a distinct dedicated NEW client is open and both URLs are set", () => {
expect(
computeRunOpsSplitReadEnabled({
newReplica: dedicatedNew,
controlPlaneWriter: cpWriter,
controlPlaneReplica: cpReplica,
hasNewUrl: true,
hasLegacyUrl: true,
})
).toBe(true);
});
// Regression: the LEGACY run-ops handle IS the control-plane replica by design. The gate must
// depend only on the NEW client's distinctness — never on the legacy handle differing from CP.
it("stays enabled even though the legacy handle equals the control-plane replica", () => {
// The caller passes controlPlaneReplica (=== legacy handle) for the CP slot; NEW is still
// distinct, so split must remain ON. (A gate that required legacy !== CP would be false here.)
expect(
computeRunOpsSplitReadEnabled({
newReplica: dedicatedNew,
controlPlaneWriter: cpWriter,
controlPlaneReplica: cpReplica, // legacy run-ops replica is this very object in prod
hasNewUrl: true,
hasLegacyUrl: true,
})
).toBe(true);
});
it("disables split when NEW falls back to the control-plane client (no dedicated DB)", () => {
expect(
computeRunOpsSplitReadEnabled({
newReplica: cpReplica, // cpFallback: NEW === control-plane replica
controlPlaneWriter: cpWriter,
controlPlaneReplica: cpReplica,
hasNewUrl: true,
hasLegacyUrl: true,
})
).toBe(false);
});
it("disables split when NEW equals the control-plane writer", () => {
expect(
computeRunOpsSplitReadEnabled({
newReplica: cpWriter,
controlPlaneWriter: cpWriter,
controlPlaneReplica: cpReplica,
hasNewUrl: true,
hasLegacyUrl: true,
})
).toBe(false);
});
it("disables split when either URL is missing, even with a distinct client", () => {
const base = {
newReplica: dedicatedNew,
controlPlaneWriter: cpWriter,
controlPlaneReplica: cpReplica,
};
expect(computeRunOpsSplitReadEnabled({ ...base, hasNewUrl: false, hasLegacyUrl: true })).toBe(
false
);
expect(computeRunOpsSplitReadEnabled({ ...base, hasNewUrl: true, hasLegacyUrl: false })).toBe(
false
);
});
// Observability regression guard: split-configured (both URLs set) but the NEW client is not a
// distinct instance must WARN loudly. Without this signal, an accidental refactor that makes the
// NEW client alias a control-plane client silently disables read fan-out with zero error/warning.
describe("warn signal when configured-but-aliased", () => {
it("warns when both URLs are set but NEW aliases the control-plane replica", () => {
const warn = vi.fn();
const enabled = computeRunOpsSplitReadEnabled({
newReplica: cpReplica, // aliasing regression: NEW === control-plane replica
controlPlaneWriter: cpWriter,
controlPlaneReplica: cpReplica,
hasNewUrl: true,
hasLegacyUrl: true,
logger: { warn },
});
expect(enabled).toBe(false);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toMatch(/split.*configured/i);
});
it("warns when both URLs are set but NEW aliases the control-plane writer", () => {
const warn = vi.fn();
const enabled = computeRunOpsSplitReadEnabled({
newReplica: cpWriter, // aliasing regression: NEW === control-plane writer
controlPlaneWriter: cpWriter,
controlPlaneReplica: cpReplica,
hasNewUrl: true,
hasLegacyUrl: true,
logger: { warn },
});
expect(enabled).toBe(false);
expect(warn).toHaveBeenCalledTimes(1);
});
it("does NOT warn in ordinary single mode (both URLs unset, clients naturally aliased)", () => {
const warn = vi.fn();
const enabled = computeRunOpsSplitReadEnabled({
newReplica: cpReplica,
controlPlaneWriter: cpWriter,
controlPlaneReplica: cpReplica,
hasNewUrl: false,
hasLegacyUrl: false,
logger: { warn },
});
expect(enabled).toBe(false);
expect(warn).not.toHaveBeenCalled();
});
it("does NOT warn when only one URL is set (not truly configured for split)", () => {
const warn = vi.fn();
computeRunOpsSplitReadEnabled({
newReplica: cpReplica,
controlPlaneWriter: cpWriter,
controlPlaneReplica: cpReplica,
hasNewUrl: true,
hasLegacyUrl: false,
logger: { warn },
});
expect(warn).not.toHaveBeenCalled();
});
it("does NOT warn when the NEW client is genuinely distinct (healthy split)", () => {
const warn = vi.fn();
const enabled = computeRunOpsSplitReadEnabled({
newReplica: dedicatedNew,
controlPlaneWriter: cpWriter,
controlPlaneReplica: cpReplica,
hasNewUrl: true,
hasLegacyUrl: true,
logger: { warn },
});
expect(enabled).toBe(true);
expect(warn).not.toHaveBeenCalled();
});
it("does not throw when no logger is supplied (logger stays optional)", () => {
expect(() =>
computeRunOpsSplitReadEnabled({
newReplica: cpReplica,
controlPlaneWriter: cpWriter,
controlPlaneReplica: cpReplica,
hasNewUrl: true,
hasLegacyUrl: true,
})
).not.toThrow();
});
});
});