Skip to content

Commit f98e303

Browse files
authored
feat(webapp): resolve which shard an environment mints run roots into (#4755)
## Summary Adds the shard-selection stage of run-id minting. `resolveMintShard(env)` returns which run-ops database an environment mints its new run roots into: the active shard list, then a fleet-wide override, then a per-environment or per-organization pin, then a rendezvous hash of the environment id. That half is inert. Nothing calls `resolveMintShard`, no deployment has any of the new flags set, and an empty active list returns the current answer without reading anything. **The other half is not inert, and it is where review effort belongs.** To stamp a grace window this needs a read-then-write under a lock, so it rewrites the global feature-flag write path that `runOpsMintKind` already depends on in production. See below. ## Placement Resolution reads the active list from a global flag, applies the grace window, and then picks: - a fleet-wide override if one is set, which is how a cutover completes without visiting each organization. `new` holds the whole fleet on the current id format. - otherwise a per-environment or per-organization pin. `new` holds one organization back while the rest move, which is how a canary works. - otherwise a rendezvous hash, so adding a shard moves only about 1/(N+1) of environments and removing one moves only its own. Two hash details are load-bearing. Scores are 64-bit `sha256(envId \0 key)`, because a 32-bit score collides at our environment count and an undetected tie would resolve by iteration order. The parsed key list is sorted, because otherwise two deployments listing the same shards in a different CSV order would place environments differently. A pin or override naming a shard that has left the active list falls through to the hash and reports once. Honouring it would leak the drain the active list exists to perform, and throwing would fail triggers whenever a pinned shard drains. ## Why the active list is a flag and not an environment variable A deploy rolls for hours, so two pods hold two different environment values at the same time. A list held in the environment therefore splits the fleet for the length of the rollout, with new pods placing an environment on one shard and old pods on another. A grace window measured in seconds cannot cover that, and the same knob times the existing mint-kind flip so it cannot simply be lengthened. An environment variable also cannot record its own flip time, and an operator cannot know a rollout's end in advance. So the list, its grace stamp and the override are global flags, written server-side against the control-plane clock under an advisory lock. This branch adds no environment variables. ## The write path, which is live Stamping generalises to any number of graced flag groups in one transaction under one lock. That has three consequences a reviewer should look at directly: - It closes a real bug. `runOpsMintKind` is an editable control on the global flags page, and that page previously wrote it with a bare upsert: no lock, no stamp. An operator flipping mint kind through the UI got an ungraced flip, so every pod crossed the cutover at a different moment. Verified against a running instance, before and after. - A graced group is all-or-nothing. Submitting its primary writes the group with a fresh stamp; omitting it deletes the primary and its stamp together, because a stamp left without its primary keeps being served and would mint into a shard just removed. - The advisory lock takes the previous id as well as the current one, in a fixed order, so writers on an older release still serialise during a rollout. The legacy id can be dropped one release after this ships. This folds with #4751 rather than replacing it: its `unlockLockedFlags` rule decides what the sweep may delete, and the graced groups keep their stamp under the lock. Both sets of tests pass. ## Notes for review Determinism is a property of the pure core for fixed inputs. The wrapper supplies the clock, the same split `effectiveMintKind` already uses. A failed read of the list falls back to the current id format rather than guessing. Six flags appear in the admin pages immediately. The two pins are per-organization, so they render read-only on the global page. The list, its stamp and the override are deployment-wide, so they render read-only in the organization dialog. Nothing bounds the active list against shards that actually exist. That is safe while nothing mints, but the change that carries a shard key into an id must land after the shard descriptors bound the list, or bound it itself.
1 parent b55fba9 commit f98e303

17 files changed

Lines changed: 2192 additions & 123 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { derivedFlagsClearedWith } from "~/v3/featureFlags";
2+
3+
export type FlagChange =
4+
| { key: string; type: "added"; newVal: string }
5+
| { key: string; type: "removed"; oldVal: string }
6+
| { key: string; type: "changed"; oldVal: string; newVal: string };
7+
8+
/**
9+
* What a global flag save will do, for the confirm dialog.
10+
*
11+
* A graced primary that is unset also clears its stamps. Those keys are locked, so the caller
12+
* filters them out of `initialValues` — the cascade therefore reads `storedValues`, which is the
13+
* unfiltered set the loader returned. Reading `initialValues` finds nothing and understates the
14+
* deletion, which is the defect this parameter exists to prevent.
15+
*/
16+
export function buildFlagChangeList(params: {
17+
editableKeys: readonly string[];
18+
lockedKeys: readonly string[];
19+
initialValues: Record<string, unknown>;
20+
storedValues: Record<string, unknown>;
21+
newValues: Record<string, unknown>;
22+
}): FlagChange[] {
23+
const { editableKeys, initialValues, storedValues, newValues } = params;
24+
25+
return editableKeys.flatMap<FlagChange>((key) => {
26+
const wasSet = key in initialValues;
27+
const isSet = key in newValues;
28+
const oldVal = initialValues[key];
29+
const newVal = newValues[key];
30+
31+
if (!wasSet && !isSet) return [];
32+
if (wasSet && isSet && stableValue(oldVal) === stableValue(newVal)) return [];
33+
34+
if (!wasSet && isSet) {
35+
return [{ key, type: "added", newVal: String(newVal) }];
36+
}
37+
38+
if (wasSet && !isSet) {
39+
// Only an unset clears the stamps. A change re-stamps instead.
40+
const cascaded = derivedFlagsClearedWith(key)
41+
.filter((derived) => derived in storedValues)
42+
.map<FlagChange>((derived) => ({
43+
key: derived,
44+
type: "removed",
45+
oldVal: String(storedValues[derived]),
46+
}));
47+
return [{ key, type: "removed", oldVal: String(oldVal) }, ...cascaded];
48+
}
49+
50+
return [{ key, type: "changed", oldVal: String(oldVal), newVal: String(newVal) }];
51+
});
52+
}
53+
54+
function stableValue(value: unknown): string {
55+
return JSON.stringify(value ?? null);
56+
}

apps/webapp/app/routes/admin.api.v1.feature-flags.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { json } from "@remix-run/server-runtime";
33
import { prisma } from "~/db.server";
44
import { env } from "~/env.server";
55
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
6-
import { applyGlobalMintKindFlip, makeSetMultipleFlags } from "~/v3/featureFlags.server";
6+
import {
7+
applyGlobalGracedFlips,
8+
makeSetMultipleFlags,
9+
touchesGracedGroup,
10+
withoutDerivedKeys,
11+
} from "~/v3/featureFlags.server";
712
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
813

914
export async function action({ request }: ActionFunctionArgs) {
@@ -25,19 +30,16 @@ export async function action({ request }: ActionFunctionArgs) {
2530
);
2631
}
2732

28-
// Derived grace-stamp fields are computed server-side; never trust them from the body.
29-
const {
30-
runOpsMintKindPrev: _ignoredPrev,
31-
runOpsMintKindFlippedAt: _ignoredFlippedAt,
32-
...requestedFlags
33-
} = validationResult.data;
33+
// Both the strip and the branch derive from the graced-group table, so adding a group needs
34+
// no edit here. Naming the keys inline is how a new group ends up writing its stamp straight
35+
// from the request body, with no lock.
36+
const requestedFlags = withoutDerivedKeys(validationResult.data) as Partial<
37+
typeof validationResult.data
38+
>;
3439

35-
// A global mint-kind flip stamps its grace window under a lock (applyGlobalMintKindFlip);
36-
// any other flag save writes directly.
37-
const updatedFlags =
38-
requestedFlags.runOpsMintKind !== undefined
39-
? await applyGlobalMintKindFlip(prisma, requestedFlags, env.RUN_OPS_MINT_FLIP_GRACE_MS)
40-
: await makeSetMultipleFlags(prisma)(requestedFlags);
40+
const updatedFlags = touchesGracedGroup(requestedFlags)
41+
? await applyGlobalGracedFlips(prisma, requestedFlags, env.RUN_OPS_MINT_FLIP_GRACE_MS)
42+
: await makeSetMultipleFlags(prisma)(requestedFlags);
4143

4244
return json({
4345
success: true,

apps/webapp/app/routes/admin.feature-flags.tsx

Lines changed: 17 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type FeatureFlagKey,
1515
type FlagControlType,
1616
getAllFlagControlTypes,
17+
lockedFlagsInPayload,
1718
validatePartialFeatureFlags,
1819
} from "~/v3/featureFlags";
1920
import { flags as getGlobalFlags, replaceGlobalFeatureFlags } from "~/v3/featureFlags.server";
@@ -29,6 +30,7 @@ import {
2930
DialogFooter,
3031
} from "~/components/primitives/Dialog";
3132
import { cn } from "~/utils/cn";
33+
import { buildFlagChangeList } from "~/components/admin/flagChangeList";
3234
import {
3335
UNSET_VALUE,
3436
BooleanControl,
@@ -111,17 +113,12 @@ export const action = dashboardAction(
111113

112114
const { isManagedCloud } = featuresForRequest(request);
113115

114-
// On managed cloud, reject if payload includes locked flags
115-
if (isManagedCloud) {
116-
const lockedInPayload = Object.keys(parsed.data.flags).filter((key) =>
117-
GLOBAL_LOCKED_FLAGS.includes(key)
116+
const lockedInPayload = lockedFlagsInPayload(Object.keys(parsed.data.flags), isManagedCloud);
117+
if (lockedInPayload.length > 0) {
118+
return json(
119+
{ error: `Cannot modify locked flags: ${lockedInPayload.join(", ")}` },
120+
{ status: 400 }
118121
);
119-
if (lockedInPayload.length > 0) {
120-
return json(
121-
{ error: `Cannot modify locked flags: ${lockedInPayload.join(", ")}` },
122-
{ status: 400 }
123-
);
124-
}
125122
}
126123

127124
const validationResult = validatePartialFeatureFlags(parsed.data.flags);
@@ -137,6 +134,7 @@ export const action = dashboardAction(
137134
catalogKeys: Object.keys(getAllFlagControlTypes()) as FeatureFlagKey[],
138135
isManagedCloud,
139136
unlockLockedFlags: parsed.data.unlockLockedFlags ?? false,
137+
graceMs: env.RUN_OPS_MINT_FLIP_GRACE_MS,
140138
});
141139

142140
return json({ success: true });
@@ -401,6 +399,7 @@ export default function AdminFeatureFlagsRoute() {
401399
open={confirmOpen}
402400
onOpenChange={setConfirmOpen}
403401
initialValues={initialValues}
402+
storedValues={allFlags}
404403
newValues={values}
405404
controlTypes={typedControlTypes}
406405
lockedKeys={unlocked ? [] : GLOBAL_LOCKED_FLAGS}
@@ -467,6 +466,7 @@ function ConfirmDialog({
467466
open,
468467
onOpenChange,
469468
initialValues,
469+
storedValues,
470470
newValues,
471471
controlTypes,
472472
lockedKeys,
@@ -477,6 +477,7 @@ function ConfirmDialog({
477477
open: boolean;
478478
onOpenChange: (open: boolean) => void;
479479
initialValues: Record<string, unknown>;
480+
storedValues: Record<string, unknown>;
480481
newValues: Record<string, unknown>;
481482
controlTypes: Record<string, FlagControlType>;
482483
lockedKeys: readonly string[];
@@ -488,34 +489,12 @@ function ConfirmDialog({
488489
.filter((key) => !lockedKeys.includes(key))
489490
.sort();
490491

491-
type Change =
492-
| { key: string; type: "added"; newVal: string }
493-
| { key: string; type: "removed"; oldVal: string }
494-
| { key: string; type: "changed"; oldVal: string; newVal: string };
495-
496-
const changes = editableKeys.flatMap<Change>((key) => {
497-
const wasSet = key in initialValues;
498-
const isSet = key in newValues;
499-
const oldVal = initialValues[key];
500-
const newVal = newValues[key];
501-
502-
if (!wasSet && !isSet) return [];
503-
if (wasSet && isSet && stableStringify(oldVal) === stableStringify(newVal)) return [];
504-
505-
if (!wasSet && isSet) {
506-
return [{ key, type: "added" as const, newVal: String(newVal) }];
507-
}
508-
if (wasSet && !isSet) {
509-
return [{ key, type: "removed" as const, oldVal: String(oldVal) }];
510-
}
511-
return [
512-
{
513-
key,
514-
type: "changed" as const,
515-
oldVal: String(oldVal),
516-
newVal: String(newVal),
517-
},
518-
];
492+
const changes = buildFlagChangeList({
493+
editableKeys,
494+
lockedKeys,
495+
initialValues,
496+
storedValues,
497+
newValues,
519498
});
520499

521500
return (

0 commit comments

Comments
 (0)