|
8 | 8 | FeatureFlagCatalog, |
9 | 9 | } from "~/v3/featureFlags"; |
10 | 10 | import { stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace"; |
| 11 | +import { stampMintShardSetFlip } from "~/v3/runOpsMigration/mintShardGrace"; |
| 12 | +import { boundedIn } from "~/db.server"; |
11 | 13 |
|
12 | 14 | export type FlagsOptions<T extends FeatureFlagKey> = { |
13 | 15 | key: T; |
@@ -182,41 +184,125 @@ export function makeSetMultipleFlags(_prisma: PrismaClientOrTransaction = prisma |
182 | 184 | // Read -> stamp -> write the global mint-kind grace metadata in one transaction. The three |
183 | 185 | // FeatureFlag rows may not exist yet, so a row FOR UPDATE can't lock them; an advisory xact lock |
184 | 186 | // serializes concurrent global flips so one can't clobber another's grace stamp (mirrors per-org). |
185 | | -export async function applyGlobalMintKindFlip( |
| 187 | +// Every group of global flags whose value carries its own grace stamp. One transaction and one |
| 188 | +// lock cover all of them, so a save that flips two groups can never stamp one and lose the other. |
| 189 | +const GRACED_GLOBAL_GROUPS = [ |
| 190 | + { |
| 191 | + keys: [ |
| 192 | + FEATURE_FLAG.runOpsMintKind, |
| 193 | + FEATURE_FLAG.runOpsMintKindPrev, |
| 194 | + FEATURE_FLAG.runOpsMintKindFlippedAt, |
| 195 | + ] as FeatureFlagKey[], |
| 196 | + stamp: stampMintKindFlip, |
| 197 | + }, |
| 198 | + { |
| 199 | + keys: [ |
| 200 | + FEATURE_FLAG.runOpsMintShardSet, |
| 201 | + FEATURE_FLAG.runOpsMintShardSetPrev, |
| 202 | + FEATURE_FLAG.runOpsMintShardSetFlippedAt, |
| 203 | + ] as FeatureFlagKey[], |
| 204 | + stamp: stampMintShardSetFlip, |
| 205 | + }, |
| 206 | +] as const; |
| 207 | + |
| 208 | +// Keys the graced path owns. They never take a bare upsert and never enter the replace sweep, |
| 209 | +// because a server-computed stamp must not be written from a request body nor swept away. |
| 210 | +const GRACED_GLOBAL_KEYS: FeatureFlagKey[] = GRACED_GLOBAL_GROUPS.flatMap((g) => g.keys); |
| 211 | + |
| 212 | +export async function applyGlobalGracedFlips( |
186 | 213 | client: PrismaClient, |
187 | 214 | requestedFlags: Partial<z.infer<typeof FeatureFlagCatalogSchema>>, |
188 | 215 | graceMs: number |
189 | 216 | ): Promise<{ key: string; value: any }[]> { |
190 | 217 | return client.$transaction(async (tx) => { |
191 | | - await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('runops-global-mint-kind-flip'))`; |
| 218 | + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('runops-global-graced-flag-flip'))`; |
192 | 219 |
|
193 | 220 | const existingRows = await tx.featureFlag.findMany({ |
194 | | - where: { |
195 | | - key: { |
196 | | - in: [ |
197 | | - FEATURE_FLAG.runOpsMintKind, |
198 | | - FEATURE_FLAG.runOpsMintKindPrev, |
199 | | - FEATURE_FLAG.runOpsMintKindFlippedAt, |
200 | | - ], |
201 | | - }, |
202 | | - }, |
| 221 | + where: { key: { in: GRACED_GLOBAL_KEYS } }, |
203 | 222 | select: { key: true, value: true }, |
204 | 223 | }); |
205 | 224 | const existingGlobal: Record<string, unknown> = {}; |
206 | 225 | for (const row of existingRows) { |
207 | 226 | existingGlobal[row.key] = row.value; |
208 | 227 | } |
209 | 228 |
|
210 | | - // Anchor the cutover to the control-plane DB clock, not this process's wall clock. |
| 229 | + // Anchor the cutover to the control-plane DB clock, not this process's wall clock. A rolling |
| 230 | + // deploy spans hours, so every pod must date the window against one shared clock. |
211 | 231 | const [{ now }] = await tx.$queryRaw<{ now: Date }[]>`SELECT now() AS now`; |
212 | 232 |
|
213 | | - const stamped = stampMintKindFlip( |
214 | | - existingGlobal, |
215 | | - { ...requestedFlags }, |
216 | | - now.getTime(), |
217 | | - graceMs |
218 | | - ) as Partial<z.infer<typeof FeatureFlagCatalogSchema>>; |
| 233 | + let stamped: Record<string, unknown> = { ...requestedFlags }; |
| 234 | + for (const group of GRACED_GLOBAL_GROUPS) { |
| 235 | + stamped = group.stamp(existingGlobal, stamped, now.getTime(), graceMs); |
| 236 | + } |
219 | 237 |
|
220 | | - return makeSetMultipleFlags(tx)(stamped); |
| 238 | + return makeSetMultipleFlags(tx)(stamped as Partial<z.infer<typeof FeatureFlagCatalogSchema>>); |
221 | 239 | }); |
222 | 240 | } |
| 241 | + |
| 242 | +/** @deprecated Prefer applyGlobalGracedFlips, which stamps every graced group in one lock. */ |
| 243 | +export async function applyGlobalMintKindFlip( |
| 244 | + client: PrismaClient, |
| 245 | + requestedFlags: Partial<z.infer<typeof FeatureFlagCatalogSchema>>, |
| 246 | + graceMs: number |
| 247 | +): Promise<{ key: string; value: any }[]> { |
| 248 | + return applyGlobalGracedFlips(client, requestedFlags, graceMs); |
| 249 | +} |
| 250 | + |
| 251 | +// Replace-semantics write for the global admin flags page: upsert submitted catalog flags, delete |
| 252 | +// omitted ones unless protected, and route any graced group through the stamped path. |
| 253 | +export async function replaceGlobalFeatureFlags( |
| 254 | + client: PrismaClient, |
| 255 | + params: { |
| 256 | + requestedFlags: Partial<z.infer<typeof FeatureFlagCatalogSchema>>; |
| 257 | + catalogKeys: FeatureFlagKey[]; |
| 258 | + isProtected: (key: FeatureFlagKey) => boolean; |
| 259 | + graceMs: number; |
| 260 | + } |
| 261 | +): Promise<void> { |
| 262 | + // Derived stamp fields are computed server-side; never trust them from the body. |
| 263 | + const requestedFlags: Record<string, unknown> = { ...params.requestedFlags }; |
| 264 | + for (const group of GRACED_GLOBAL_GROUPS) { |
| 265 | + for (const derived of group.keys.slice(1)) { |
| 266 | + delete requestedFlags[derived]; |
| 267 | + } |
| 268 | + } |
| 269 | + |
| 270 | + const touchesGracedGroup = GRACED_GLOBAL_GROUPS.some( |
| 271 | + (group) => requestedFlags[group.keys[0]] !== undefined |
| 272 | + ); |
| 273 | + if (touchesGracedGroup) { |
| 274 | + await applyGlobalGracedFlips( |
| 275 | + client, |
| 276 | + requestedFlags as Partial<z.infer<typeof FeatureFlagCatalogSchema>>, |
| 277 | + params.graceMs |
| 278 | + ); |
| 279 | + } |
| 280 | + |
| 281 | + const upsertOps: ReturnType<typeof client.featureFlag.upsert>[] = []; |
| 282 | + const keysToDelete: string[] = []; |
| 283 | + |
| 284 | + for (const key of params.catalogKeys) { |
| 285 | + if (GRACED_GLOBAL_KEYS.includes(key)) { |
| 286 | + continue; |
| 287 | + } |
| 288 | + if (key in requestedFlags) { |
| 289 | + const value = requestedFlags[key]; |
| 290 | + upsertOps.push( |
| 291 | + client.featureFlag.upsert({ |
| 292 | + where: { key }, |
| 293 | + create: { key, value: value as any }, |
| 294 | + update: { value: value as any }, |
| 295 | + }) |
| 296 | + ); |
| 297 | + } else if (!params.isProtected(key)) { |
| 298 | + keysToDelete.push(key); |
| 299 | + } |
| 300 | + } |
| 301 | + |
| 302 | + await client.$transaction([ |
| 303 | + ...upsertOps, |
| 304 | + ...(keysToDelete.length > 0 |
| 305 | + ? [client.featureFlag.deleteMany({ where: { key: { in: boundedIn(keysToDelete) } } })] |
| 306 | + : []), |
| 307 | + ]); |
| 308 | +} |
0 commit comments