-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): RUN_OPS_SHARDS config, topology and N-way store wiring #4764
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
d-cs
wants to merge
13
commits into
main
Choose a base branch
from
feat/run-ops-shards-tri-13429
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
abde82c
feat(core): export isValidShardChar for shard-descriptor validation
d-cs f9fb0c0
feat(run-store): add UnknownShardKey and RoutingRunStore.fromShards w…
d-cs 36a2bf9
feat(webapp): add the RUN_OPS_SHARDS zod descriptor validated at boot
d-cs ae74544
feat(webapp): resolve per-role run-ops pool knobs in one module
d-cs 42d10b0
refactor(webapp): collapse the two run-ops client builders into one f…
d-cs 2222f4c
feat(webapp): export per-shard transaction resilience with an own bud…
d-cs 432d7f5
feat(webapp): build one run-ops client pair per shard descriptor
d-cs f908f14
feat(webapp): build N dedicated stores and the keyed router, and log …
d-cs dbc22fd
feat(webapp): bound the active mint list against the configured shard…
d-cs 46e97cf
chore(webapp): unexport internal shard descriptor type and apply form…
d-cs 4f0d8ca
Merge remote-tracking branch 'origin/main' into feat/run-ops-shards-t…
d-cs 20f0ee3
refactor(webapp): address review feedback on shard wiring
d-cs 908fcb5
test(webapp): cover the dedicated-shard misconfiguration throw in sel…
d-cs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import { env } from "~/env.server"; | ||
| import type { RunOpsShardKnobs } from "~/v3/runOpsShards.server"; | ||
|
|
||
| // Pool configuration for one run-ops store (writer + replica). Kept separate from db.server (which | ||
| // ~156 tests mock wholesale) so a new export breaks no mock. | ||
| export type ResolvedPoolKnobs = { | ||
| writerPoolTimeout: number; | ||
| writerConnectionTimeout: number; | ||
| writerDriverAdapter: boolean; | ||
| connectionLimit: number; | ||
| replicaConnectionLimit: number; | ||
| replicaPoolTimeout: number; | ||
| replicaConnectionTimeout: number; | ||
| replicaDriverAdapter: boolean; | ||
| }; | ||
|
|
||
| type Role = "new" | "legacy"; | ||
|
|
||
| // PURE: overlay a gen-2 shard's descriptor knobs on a role's resolved defaults. This holds the only | ||
| // logic (per-field override), so a test drives it with literal defaults and literal overrides — | ||
| // no env import, no circular assertion against the same env expression the impl reads. | ||
| export function applyPoolKnobOverrides( | ||
| defaults: ResolvedPoolKnobs, | ||
| k?: RunOpsShardKnobs | ||
| ): ResolvedPoolKnobs { | ||
| return { | ||
| writerPoolTimeout: k?.writerPoolTimeout ?? defaults.writerPoolTimeout, | ||
| writerConnectionTimeout: k?.writerConnectionTimeout ?? defaults.writerConnectionTimeout, | ||
| writerDriverAdapter: k?.writerDriverAdapter ?? defaults.writerDriverAdapter, | ||
| connectionLimit: k?.connectionLimit ?? defaults.connectionLimit, | ||
| replicaConnectionLimit: k?.replicaConnectionLimit ?? defaults.replicaConnectionLimit, | ||
| replicaPoolTimeout: k?.replicaPoolTimeout ?? defaults.replicaPoolTimeout, | ||
| replicaConnectionTimeout: k?.replicaConnectionTimeout ?? defaults.replicaConnectionTimeout, | ||
| replicaDriverAdapter: k?.replicaDriverAdapter ?? defaults.replicaDriverAdapter, | ||
| }; | ||
| } | ||
|
|
||
| // The env-derived defaults for a role, reproducing today's run-ops builder expressions exactly. A | ||
| // flat mapping (no logic), verified by inspection against the former builders. Transaction | ||
| // resilience is a SEPARATE mechanism (resolveTransactionResilience) and is not here. | ||
| function poolKnobDefaults(role: Role): ResolvedPoolKnobs { | ||
| if (role === "legacy") { | ||
| return { | ||
| writerPoolTimeout: | ||
| env.RUN_OPS_LEGACY_DATABASE_WRITER_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT, | ||
| writerConnectionTimeout: | ||
| env.RUN_OPS_LEGACY_DATABASE_WRITER_CONNECTION_TIMEOUT ?? env.DATABASE_CONNECTION_TIMEOUT, | ||
| writerDriverAdapter: env.RUN_OPS_LEGACY_DATABASE_WRITER_DRIVER_ADAPTER === "1", | ||
| connectionLimit: env.DATABASE_CONNECTION_LIMIT, | ||
| replicaConnectionLimit: env.DATABASE_CONNECTION_LIMIT, | ||
| replicaPoolTimeout: | ||
| env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT, | ||
| replicaConnectionTimeout: | ||
| env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_CONNECTION_TIMEOUT ?? | ||
| env.DATABASE_CONNECTION_TIMEOUT, | ||
| replicaDriverAdapter: env.RUN_OPS_LEGACY_DATABASE_REPLICA_DRIVER_ADAPTER === "1", | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| writerPoolTimeout: env.RUN_OPS_DATABASE_WRITER_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT, | ||
| writerConnectionTimeout: | ||
| env.RUN_OPS_DATABASE_WRITER_CONNECTION_TIMEOUT ?? env.DATABASE_CONNECTION_TIMEOUT, | ||
| writerDriverAdapter: env.RUN_OPS_DATABASE_WRITER_DRIVER_ADAPTER === "1", | ||
| connectionLimit: env.DATABASE_CONNECTION_LIMIT, | ||
| replicaConnectionLimit: | ||
| env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT ?? env.DATABASE_CONNECTION_LIMIT, | ||
| replicaPoolTimeout: env.RUN_OPS_DATABASE_READ_REPLICA_POOL_TIMEOUT ?? env.DATABASE_POOL_TIMEOUT, | ||
| replicaConnectionTimeout: | ||
| env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_TIMEOUT ?? env.DATABASE_CONNECTION_TIMEOUT, | ||
| replicaDriverAdapter: env.RUN_OPS_DATABASE_REPLICA_DRIVER_ADAPTER === "1", | ||
| }; | ||
| } | ||
|
|
||
| export function resolveRunOpsPoolKnobs( | ||
| role: Role, | ||
| descriptorKnobs?: RunOpsShardKnobs | ||
| ): ResolvedPoolKnobs { | ||
| return applyPoolKnobOverrides(poolKnobDefaults(role), descriptorKnobs); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| // Pure boot-table helpers. Dependency-free (no db.server, no env) so a test of these two string | ||
| // functions never constructs a Prisma client. db.server imports them for the boot log. | ||
|
|
||
| // A host:port/db address, with NO username and NO query params — never a secret, and deliberately | ||
| // NOT an identity claim (two DSNs can share an address yet be different databases; that proof is the | ||
| // distinctness sentinel's, not this line's). Same tuple sameDatabaseTarget compares, kept in step. | ||
| export function runOpsAddressFingerprint(url: string): string { | ||
| try { | ||
| const u = new URL(url); | ||
| return `${u.hostname}:${u.port || "5432"}${u.pathname}`; | ||
| } catch { | ||
| return "unparseable"; | ||
| } | ||
| } | ||
|
|
||
| export type RunOpsShardTableRow = { key: string; fingerprint: string; role: string }; | ||
|
|
||
| // The resolved shard table for the boot log: one row per descriptor. An alias reports its role and | ||
| // carries no address (it shares the new store's pool). | ||
| export function buildRunOpsShardTable( | ||
| descriptors: Array<{ key: string; url?: string; aliasOf?: "new" }> | ||
| ): RunOpsShardTableRow[] { | ||
| return descriptors.map((d) => | ||
| d.aliasOf | ||
| ? { key: d.key, fingerprint: "alias(new)", role: "alias(new)" } | ||
| : { key: d.key, fingerprint: runOpsAddressFingerprint(d.url ?? ""), role: "shard" } | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { z } from "zod"; | ||
| import { isValidShardChar } from "@trigger.dev/core/v3/isomorphic"; | ||
| import { isValidDatabaseUrl } from "~/utils/db"; | ||
|
|
||
| const KnobsSchema = z | ||
| .object({ | ||
| writerPoolTimeout: z.number().int().optional(), | ||
| writerConnectionTimeout: z.number().int().optional(), | ||
| writerDriverAdapter: z.boolean().optional(), | ||
| connectionLimit: z.number().int().optional(), | ||
| replicaConnectionLimit: z.number().int().optional(), | ||
| replicaPoolTimeout: z.number().int().optional(), | ||
| replicaConnectionTimeout: z.number().int().optional(), | ||
| replicaDriverAdapter: z.boolean().optional(), | ||
| transactionMaxWaitMs: z.number().int().optional(), | ||
| transactionStartRetryEnabled: z.boolean().optional(), | ||
| transactionStartRetryMaxAttempts: z.number().int().optional(), | ||
| transactionStartRetryBackoffMinMs: z.number().int().optional(), | ||
| transactionStartRetryBackoffMaxMs: z.number().int().optional(), | ||
| transactionStartRetryBudgetPerSec: z.number().int().optional(), | ||
| transactionStartRetryBudgetBurst: z.number().int().optional(), | ||
| }) | ||
| .strict(); | ||
| export type RunOpsShardKnobs = z.infer<typeof KnobsSchema>; | ||
|
|
||
| const ReplicationSchema = z.object({ | ||
| slotName: z.string().min(1), | ||
| publicationName: z.string().min(1), | ||
| originGeneration: z.number().int().min(2).max(255), | ||
| }); | ||
|
|
||
| const DescriptorSchema = z | ||
| .object({ | ||
| key: z.string().refine(isValidShardChar, "shard key must be a single [a-z0-9] char"), | ||
| region: z.string().min(1), | ||
| url: z.string().refine(isValidDatabaseUrl, "url is invalid").optional(), | ||
| replicaUrl: z.string().refine(isValidDatabaseUrl, "replicaUrl is invalid").optional(), | ||
| directUrl: z.string().refine(isValidDatabaseUrl, "directUrl is invalid").optional(), | ||
| replication: ReplicationSchema.optional(), | ||
| knobs: KnobsSchema.optional(), | ||
| aliasOf: z.literal("new").optional(), | ||
| }) | ||
| .strict() | ||
| .superRefine((d, ctx) => { | ||
| const hasUrl = d.url !== undefined; | ||
| const hasAlias = d.aliasOf !== undefined; | ||
| if (hasUrl === hasAlias) { | ||
| ctx.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: "exactly one of url or aliasOf is required", | ||
| }); | ||
| } | ||
| if (!hasAlias && d.replication === undefined) { | ||
| ctx.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: "replication is required unless aliasOf is set", | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| export type RunOpsShardDescriptor = z.infer<typeof DescriptorSchema>; | ||
|
|
||
| // Boot-validated transform, in the style of parseMachinePresetCsv. Undefined and "" both mean the | ||
| // off state and resolve to []. The undefined guard is load-bearing: an unguarded JSON.parse would | ||
| // kill every single-DB boot, which never sets this variable. | ||
| export function parseRunOpsShards( | ||
| raw: string | undefined, | ||
| ctx: z.RefinementCtx | ||
| ): RunOpsShardDescriptor[] { | ||
| if (raw === undefined || raw.trim() === "") return []; | ||
|
|
||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(raw); | ||
| } catch { | ||
| ctx.addIssue({ code: z.ZodIssueCode.custom, message: "RUN_OPS_SHARDS is not valid JSON" }); | ||
| return z.NEVER; | ||
| } | ||
|
|
||
| const result = z.array(DescriptorSchema).safeParse(parsed); | ||
| if (!result.success) { | ||
| for (const issue of result.error.issues) { | ||
| ctx.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: `RUN_OPS_SHARDS[${issue.path.join(".")}]: ${issue.message}`, | ||
| }); | ||
| } | ||
| return z.NEVER; | ||
| } | ||
|
|
||
| const keys = new Set<string>(); | ||
| const gens = new Set<number>(); | ||
| for (const d of result.data) { | ||
| if (keys.has(d.key)) { | ||
| ctx.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: `RUN_OPS_SHARDS: duplicate key ${d.key}`, | ||
| }); | ||
| return z.NEVER; | ||
| } | ||
| keys.add(d.key); | ||
| if (d.replication) { | ||
| if (gens.has(d.replication.originGeneration)) { | ||
| ctx.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: `RUN_OPS_SHARDS: duplicate originGeneration ${d.replication.originGeneration}`, | ||
| }); | ||
| return z.NEVER; | ||
| } | ||
| gens.add(d.replication.originGeneration); | ||
| } | ||
| } | ||
|
|
||
| return result.data; | ||
| } | ||
|
|
||
| // A non-empty shard list requires the gen-1 new store, because gen-1 v1 ids resolve to "new" | ||
| // forever (append-only). Pure so the boot refinement and its test share one rule. | ||
| export function validateShardListAgainstNewUrl( | ||
| shards: RunOpsShardDescriptor[], | ||
| newUrl: string | undefined | ||
| ): boolean { | ||
| return shards.length === 0 || !!newUrl; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.