Files
nicktrn 7aa871f37b feat(webapp): plan-aware compute migration (#3957)
Adds an opt-in mechanism to route a configurable percentage of
organizations onto the compute (MicroVM) backing of their region at
trigger time, without changing their stored region settings.

Routing is gated by three global feature flags -
`computeMigrationEnabled`, `computeMigrationFreePercentage`,
`computeMigrationPaidPercentage` - plus a per-org
`computeMigrationEnabled` override that wins in both directions. A
region's compute backing is resolved from a new
`WorkerInstanceGroup.region` column: a container group and its MicroVM
group share one geo `region`, so the migration swaps the resolved worker
queue to the backing group's queue. Orgs are bucketed deterministically
by id, so ramping a percentage down keeps a strict subset rather than
reshuffling, and a region with no compute backing is never touched.
Everything is off by default - behaviour is unchanged unless the flags
are set.

The flags and the worker-region groups are read on the trigger hot path
from in-memory snapshots rather than the database: a small
`createReloadingRegistry` helper loads each at startup and refreshes
them on an interval, so no per-trigger query is added and a percentage
or kill-switch change propagates within the reload interval. A cold
replica whose snapshot hasn't loaded yet reads as not-migrated (the
container path) and self-corrects on the next load - the same cold-start
contract as the datastore / LLM-pricing registries, with a
`reloading_registry_loaded` metric so a never-loaded registry is
alertable.

The same migration decision is consulted at deploy-time template
creation so a migrated org gets a compute template built ahead of its
first run. This runs in shadow mode (best-effort, never fails the
deploy) by default, or - when the `computeMigrationRequireTemplate` flag
is on - in required mode, built synchronously at deploy so the first run
never builds on-demand and template errors surface at deploy time.

So operators keep "which runs ran where" while customers only see
geography: the run's actual worker queue is stored raw, and the geo
region is stamped separately on `TaskRun.region` (and a new ClickHouse
`region` column) at trigger time. Read surfaces - the dashboard, the
API, and the Query/Logs page - show the geo region, falling back to the
worker queue for runs written before the column existed.

Minor follow-ups left out of scope: the percentage flags render as text
inputs on the admin flags page (the catalog UI has no numeric control
type yet), and `createReloadingRegistry` could later gain pub/sub for
sub-second cross-replica propagation if the reload interval proves too
slow.
2026-06-17 08:28:15 +01:00

80 lines
2.3 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { createReloadingRegistry } from "~/utils/reloadingRegistry.server";
describe("createReloadingRegistry", () => {
it("current() is undefined before load, snapshot after isReady", async () => {
const reg = createReloadingRegistry({
name: "test-a",
intervalMs: 10_000,
load: async () => ({ value: 42 }),
});
expect(reg.current()).toBeUndefined();
await reg.isReady;
expect(reg.isLoaded).toBe(true);
expect(reg.current()).toEqual({ value: 42 });
reg.stop();
});
it("reload() picks up a changed value", async () => {
let v = 1;
const reg = createReloadingRegistry({
name: "test-d",
intervalMs: 10_000,
load: async () => v,
});
await reg.isReady;
expect(reg.current()).toBe(1);
v = 2;
await reg.reload();
expect(reg.current()).toBe(2);
reg.stop();
});
it("newer load wins even if an older load resolves later", async () => {
// load hands the test a deferred resolver per call so completion order is controllable.
const deferred: Array<(value: number) => void> = [];
const reg = createReloadingRegistry({
name: "test-e",
intervalMs: 10_000,
load: () =>
new Promise<number>((resolve) => {
deferred.push(resolve);
}),
});
// deferred[0] is the startup load; let it complete with an initial value.
deferred[0](0);
await reg.isReady;
// start two overlapping loads; don't await yet (deferred[1] older, deferred[2] newer)
const older = reg.reload();
const newer = reg.reload();
// resolve the NEWER load first, then the OLDER load last
deferred[2](2);
deferred[1](1);
await Promise.all([older, newer]);
// the older load completing last must NOT clobber the newer snapshot
expect(reg.current()).toBe(2);
reg.stop();
});
it("autoStart:false stays inert (never loads)", async () => {
let loadCalls = 0;
const reg = createReloadingRegistry({
name: "test-inert",
intervalMs: 10_000,
autoStart: false,
load: async () => {
loadCalls++;
return 1;
},
});
expect(reg.isLoaded).toBe(false);
expect(reg.current()).toBeUndefined();
expect(loadCalls).toBe(0); // never hit the DB/load
reg.stop();
});
});