| 1 | import { Query } from "@distilled.cloud/core/query"; |
| 2 | import { Railway } from "@distilled.cloud/railway"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import { withEnvironmentConfigLock } from "./transient.ts"; |
| 7 | |
| 8 | /** |
| 9 | * `deploy.multiRegionConfig` on one service. `null` removes a region. |
| 10 | * Railway ignores `ServiceInstance.region`; this map is the placement. |
| 11 | */ |
| 12 | export type RegionPlacement = Record< |
| 13 | string, |
| 14 | { readonly numReplicas?: number | null } | null |
| 15 | >; |
| 16 | |
| 17 | export class ServiceRegionNotApplied extends Data.TaggedError( |
| 18 | "Railway.ServiceRegionNotApplied", |
| 19 | )<{ |
| 20 | serviceId: string; |
| 21 | environmentId: string; |
| 22 | region: string; |
| 23 | observed: string | undefined; |
| 24 | }> { |
| 25 | override get message() { |
| 26 | const where = this.observed ?? "no region"; |
| 27 | return `Railway service ${this.serviceId} is in ${where} after requesting ${this.region}`; |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | class ServiceRegionPending extends Data.TaggedError( |
| 32 | "Railway.ServiceRegionPending", |
| 33 | )<{ |
| 34 | serviceId: string; |
| 35 | region: string; |
| 36 | }> {} |
| 37 | |
| 38 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 39 | value !== null && typeof value === "object" && !Array.isArray(value); |
| 40 | |
| 41 | /** Replicas Railway is actually placing. Empty objects and nulls are absent. */ |
| 42 | export const activeReplicaRegions = ( |
| 43 | placement: RegionPlacement, |
| 44 | ): Array<{ region: string; replicas: number }> => { |
| 45 | const active: Array<{ region: string; replicas: number }> = []; |
| 46 | for (const [region, value] of Object.entries(placement)) { |
| 47 | const replicas = value?.numReplicas; |
| 48 | if (typeof replicas === "number" && replicas >= 1) { |
| 49 | active.push({ region, replicas }); |
| 50 | } |
| 51 | } |
| 52 | return active; |
| 53 | }; |
| 54 | |
| 55 | /** |
| 56 | * The service's region when exactly one region has replicas. Several |
| 57 | * regions means there is no single region. An empty map falls back to |
| 58 | * the legacy `ServiceInstance.region` field. |
| 59 | */ |
| 60 | export const observedRegion = ( |
| 61 | placement: RegionPlacement, |
| 62 | legacy: string | null | undefined, |
| 63 | ): string | undefined => { |
| 64 | const active = activeReplicaRegions(placement); |
| 65 | const only = active[0]; |
| 66 | if (active.length === 1 && only !== undefined) return only.region; |
| 67 | if (active.length > 1) return undefined; |
| 68 | return legacy != null && legacy.length > 0 ? legacy : undefined; |
| 69 | }; |
| 70 | |
| 71 | /** |
| 72 | * Patch that leaves `desired` as the only region with replicas. |
| 73 | * `undefined` when that is already true. An existing count on `desired` |
| 74 | * is kept; otherwise the current replicas move with the service. |
| 75 | */ |
| 76 | export const regionPlacementPatch = ( |
| 77 | desired: string, |
| 78 | placement: RegionPlacement, |
| 79 | fallbackReplicas?: number, |
| 80 | ): Record<string, { numReplicas: number } | null> | undefined => { |
| 81 | const active = activeReplicaRegions(placement); |
| 82 | const only = active[0]; |
| 83 | if (active.length === 1 && only !== undefined && only.region === desired) { |
| 84 | return undefined; |
| 85 | } |
| 86 | |
| 87 | const current = active.find((row) => row.region === desired)?.replicas; |
| 88 | const moved = active.reduce((sum, row) => sum + row.replicas, 0); |
| 89 | const fallback = |
| 90 | typeof fallbackReplicas === "number" && fallbackReplicas >= 1 |
| 91 | ? fallbackReplicas |
| 92 | : 1; |
| 93 | const numReplicas = current ?? (moved > 0 ? moved : fallback); |
| 94 | const patch: Record<string, { numReplicas: number } | null> = { |
| 95 | [desired]: { numReplicas }, |
| 96 | }; |
| 97 | for (const region of Object.keys(placement)) { |
| 98 | if (region !== desired) patch[region] = null; |
| 99 | } |
| 100 | return patch; |
| 101 | }; |
| 102 | |
| 103 | /** `deploy.multiRegionConfig` for `serviceId` inside an environment config. */ |
| 104 | export const serviceRegionPlacement = ( |
| 105 | config: unknown, |
| 106 | serviceId: string, |
| 107 | ): RegionPlacement => { |
| 108 | if (!isRecord(config)) return {}; |
| 109 | const services = config.services; |
| 110 | if (!isRecord(services)) return {}; |
| 111 | const service = services[serviceId]; |
| 112 | if (!isRecord(service)) return {}; |
| 113 | const deploy = service.deploy; |
| 114 | if (!isRecord(deploy)) return {}; |
| 115 | const multiRegionConfig = deploy.multiRegionConfig; |
| 116 | if (!isRecord(multiRegionConfig)) return {}; |
| 117 | |
| 118 | const placement: RegionPlacement = {}; |
| 119 | for (const [region, value] of Object.entries(multiRegionConfig)) { |
| 120 | if (value === null) { |
| 121 | placement[region] = null; |
| 122 | continue; |
| 123 | } |
| 124 | if (!isRecord(value)) continue; |
| 125 | const replicas = value.numReplicas; |
| 126 | placement[region] = |
| 127 | typeof replicas === "number" ? { numReplicas: replicas } : {}; |
| 128 | } |
| 129 | return placement; |
| 130 | }; |
| 131 | |
| 132 | const readEnvironmentConfig = Query.fn( |
| 133 | (args: { id: string; projectId?: string }) => ({ |
| 134 | config: Railway.environment(args).config, |
| 135 | }), |
| 136 | ); |
| 137 | |
| 138 | const environmentPatchCommit = Query.fn( |
| 139 | (args: { environmentId: string; commitMessage: string; patch: unknown }) => |
| 140 | Railway.environmentPatchCommit(args), |
| 141 | ); |
| 142 | |
| 143 | const readPlacement = (input: { |
| 144 | environmentId: string; |
| 145 | projectId: string; |
| 146 | serviceId: string; |
| 147 | }) => |
| 148 | readEnvironmentConfig( |
| 149 | input.projectId.length > 0 |
| 150 | ? { id: input.environmentId, projectId: input.projectId } |
| 151 | : { id: input.environmentId }, |
| 152 | ).pipe( |
| 153 | Effect.map((environment) => |
| 154 | serviceRegionPlacement(environment.config, input.serviceId), |
| 155 | ), |
| 156 | Effect.catchTag("RailwayNotFound", () => |
| 157 | Effect.succeed({} as RegionPlacement), |
| 158 | ), |
| 159 | ); |
| 160 | |
| 161 | /** Region reported by the environment config, without writing. */ |
| 162 | export const readServiceRegion = Effect.fn(function* (input: { |
| 163 | environmentId: string; |
| 164 | projectId: string; |
| 165 | serviceId: string; |
| 166 | legacy?: string | null; |
| 167 | }) { |
| 168 | if (input.environmentId.length === 0 || input.serviceId.length === 0) { |
| 169 | return observedRegion({}, input.legacy); |
| 170 | } |
| 171 | const placement = yield* readPlacement(input); |
| 172 | return observedRegion(placement, input.legacy); |
| 173 | }); |
| 174 | |
| 175 | const waitUntilRegion = (input: { |
| 176 | environmentId: string; |
| 177 | projectId: string; |
| 178 | serviceId: string; |
| 179 | region: string; |
| 180 | legacy?: string | null; |
| 181 | }) => |
| 182 | readServiceRegion(input).pipe( |
| 183 | Effect.flatMap((observed) => |
| 184 | observed === input.region |
| 185 | ? Effect.succeed(input.region) |
| 186 | : Effect.fail( |
| 187 | new ServiceRegionPending({ |
| 188 | serviceId: input.serviceId, |
| 189 | region: input.region, |
| 190 | }), |
| 191 | ), |
| 192 | ), |
| 193 | Effect.retry({ |
| 194 | while: (error) => error._tag === "Railway.ServiceRegionPending", |
| 195 | schedule: Schedule.spaced("1 second"), |
| 196 | times: 10, |
| 197 | }), |
| 198 | Effect.catchTag("Railway.ServiceRegionPending", () => |
| 199 | readServiceRegion(input).pipe( |
| 200 | Effect.flatMap((observed) => |
| 201 | observed === input.region |
| 202 | ? Effect.succeed(input.region) |
| 203 | : Effect.fail( |
| 204 | new ServiceRegionNotApplied({ |
| 205 | serviceId: input.serviceId, |
| 206 | environmentId: input.environmentId, |
| 207 | region: input.region, |
| 208 | observed, |
| 209 | }), |
| 210 | ), |
| 211 | ), |
| 212 | ), |
| 213 | ), |
| 214 | ); |
| 215 | |
| 216 | /** |
| 217 | * Make `region` the only replica placement, or read the current one when |
| 218 | * `region` is omitted. Replica moves go through `environmentPatchCommit` |
| 219 | * (`services[id].deploy.multiRegionConfig`), the same patch Railway's CLI |
| 220 | * applies for `railway scale`. |
| 221 | */ |
| 222 | export const syncServiceRegion = Effect.fn(function* (input: { |
| 223 | environmentId: string; |
| 224 | projectId: string; |
| 225 | serviceId: string; |
| 226 | region: string | undefined; |
| 227 | legacy?: string | null; |
| 228 | fallbackReplicas?: number | null; |
| 229 | }) { |
| 230 | if (input.region === undefined) return yield* readServiceRegion(input); |
| 231 | const desired = input.region; |
| 232 | const patched = yield* withEnvironmentConfigLock( |
| 233 | input.environmentId, |
| 234 | Effect.gen(function* () { |
| 235 | const placement = yield* readPlacement(input); |
| 236 | const patch = regionPlacementPatch( |
| 237 | desired, |
| 238 | placement, |
| 239 | input.fallbackReplicas ?? undefined, |
| 240 | ); |
| 241 | if (patch === undefined) return false; |
| 242 | yield* environmentPatchCommit({ |
| 243 | environmentId: input.environmentId, |
| 244 | commitMessage: `Pin Railway service region to ${desired}`, |
| 245 | patch: { |
| 246 | services: { |
| 247 | [input.serviceId]: { |
| 248 | deploy: { multiRegionConfig: patch }, |
| 249 | }, |
| 250 | }, |
| 251 | }, |
| 252 | }); |
| 253 | return true; |
| 254 | }), |
| 255 | ); |
| 256 | if (!patched) return desired; |
| 257 | return yield* waitUntilRegion({ ...input, region: desired }); |
| 258 | }); |