1import * as bigqueryreservation from "@distilled.cloud/gcp/bigqueryreservation_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 createInternalLabels,
14 diffLabels,
15 hasAlchemyLabels,
16 stripInternalLabels,
17 toLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20
21const DEFAULT_EDITION = "ENTERPRISE";
22const DEFAULT_SLOT_CAPACITY = "0";
23const MAX_NAME_LENGTH = 64;
24
25export type ReservationEdition =
26 | bigqueryreservation.ReservationEditionEnum
27 | (string & {});
28
29export type ReservationScalingMode =
30 | bigqueryreservation.ReservationScalingModeEnum
31 | (string & {});
32
33export type ReservationAutoscale = {
34 /**
35 * Maximum extra slots the reservation may add when demand exceeds
36 * baseline `slotCapacity`.
37 */
38 maxSlots?: string;
39};
40
41export type ReservationSchedulingPolicy = {
42 /**
43 * Soft per-project slot cap for queries in this reservation. Preview.
44 */
45 maxSlots?: string;
46 /**
47 * Soft per-project job concurrency cap. Preview.
48 */
49 concurrency?: string;
50};
51
52export type ReservationProps = {
53 /**
54 * Reservation id (the `{reservation}` segment of
55 * `projects/{project}/locations/{location}/reservations/{reservation}`).
56 * If omitted, a unique name is generated from the stack, stage, and
57 * logical id. Must contain only lowercase letters, digits, or dashes;
58 * start with a letter; not end with a dash; max 64 characters.
59 * Immutable — changing it replaces the reservation.
60 */
61 reservationId?: string;
62 /**
63 * BigQuery location (`us-central1`, `US`, `EU`, …). Immutable —
64 * changing it replaces the reservation. Multi-regions `US` and `EU`
65 * stay uppercase; regional ids are lowercased (`US-CENTRAL1` becomes
66 * `us-central1`).
67 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
68 */
69 location?: string;
70 /**
71 * Baseline slots. `"0"` is valid for edition reservations that rely on
72 * idle slots or autoscale. Immutable identity is unchanged; the value
73 * itself is mutable.
74 * @default "0"
75 */
76 slotCapacity?: string;
77 /**
78 * When true, jobs using this reservation cannot burst into idle slots
79 * from sibling reservations. STANDARD edition requires this to be true.
80 * @default true when edition is STANDARD, otherwise false
81 */
82 ignoreIdleSlots?: boolean;
83 /**
84 * Reservation edition. Immutable — changing it replaces the
85 * reservation. `STANDARD` cannot set a non-zero baseline
86 * `slotCapacity` (use `autoscale.maxSlots` instead).
87 * @default "ENTERPRISE"
88 */
89 edition?: ReservationEdition;
90 /**
91 * User labels. Alchemy ownership labels are merged in automatically.
92 */
93 labels?: Record<string, string>;
94 /**
95 * Autoscale ceiling. Mutually exclusive with `maxSlots` plus
96 * `scalingMode`. `STANDARD` reservations are autoscale-only.
97 */
98 autoscale?: ReservationAutoscale;
99 /**
100 * Soft target for concurrent jobs. `"0"` lets BigQuery pick.
101 */
102 concurrency?: string;
103 /**
104 * Overall slot cap covering baseline, idle, and scaled slots. Must be
105 * set together with `scalingMode`.
106 */
107 maxSlots?: string;
108 /**
109 * How the reservation uses idle and autoscale slots when `maxSlots` is
110 * set (`AUTOSCALE_ONLY`, `IDLE_SLOTS_ONLY`, `ALL_SLOTS`).
111 */
112 scalingMode?: ReservationScalingMode;
113 /**
114 * Reservation group this reservation belongs to, as
115 * `projects/{project}/locations/{location}/reservationGroups/{group}`
116 * or a bare group id.
117 */
118 reservationGroup?: string;
119 /**
120 * Secondary location for managed disaster recovery. Setting this on
121 * create or update converts the reservation into a failover
122 * reservation.
123 */
124 secondaryLocation?: string;
125 /**
126 * Place the reservation in the organization's DR secondary region
127 * (multi-region `US` or `EU` only). Preview; the project must be
128 * allow-listed. Immutable — changing it replaces the reservation.
129 */
130 multiRegionAuxiliary?: boolean;
131 /**
132 * Per-project scheduling caps. Preview.
133 */
134 schedulingPolicy?: ReservationSchedulingPolicy;
135};
136
137export type Reservation = Resource<
138 "GCP.BigQueryReservation.Reservation",
139 ReservationProps,
140 {
141 /** Full resource name `projects/{project}/locations/{location}/reservations/{reservation}`. */
142 name: string;
143 /** Reservation id (last path segment). */
144 reservationId: string;
145 /** Project id. */
146 project: string;
147 /** Location id (`us-central1`, `US`, …). */
148 location: string;
149 /** User labels (Alchemy ownership labels stripped). */
150 labels: Record<string, string>;
151 /** Baseline slot capacity. */
152 slotCapacity: string | undefined;
153 /** Whether idle slots from siblings are ignored. */
154 ignoreIdleSlots: boolean;
155 /** Reservation edition (`STANDARD`, `ENTERPRISE`, …). */
156 edition: string | undefined;
157 /** Autoscale settings, including current extra slots. */
158 autoscale: bigqueryreservation.Autoscale | undefined;
159 /** Job concurrency target. */
160 concurrency: string | undefined;
161 /** Overall slot cap, if set. */
162 maxSlots: string | undefined;
163 /** Scaling mode used with `maxSlots`. */
164 scalingMode: string | undefined;
165 /** Reservation group, if any. */
166 reservationGroup: string | undefined;
167 /** Secondary DR location, if any. */
168 secondaryLocation: string | undefined;
169 /** Current primary replica location (failover reservations). */
170 primaryLocation: string | undefined;
171 /** Original primary location used for billing failover reservations. */
172 originalPrimaryLocation: string | undefined;
173 /** Whether this is a multi-region auxiliary reservation. */
174 multiRegionAuxiliary: boolean;
175 /** Per-project scheduling policy. */
176 schedulingPolicy: bigqueryreservation.SchedulingPolicy | undefined;
177 /** DR replication status of the primary replica. */
178 replicationStatus: bigqueryreservation.ReplicationStatus | undefined;
179 /** RFC3339 creation timestamp. */
180 creationTime: string | undefined;
181 /** RFC3339 last-update timestamp. */
182 updateTime: string | undefined;
183 },
184 never,
185 Providers
186>;
187
188/**
189 * A BigQuery slot reservation — a named pool of slots that assignments
190 * can consume.
191 *
192 * Changing `reservationId`, `location`, `edition`, or
193 * `multiRegionAuxiliary` replaces the reservation. Baseline capacity,
194 * idle-slot behavior, labels, autoscale, and failover location are
195 * updated in place.
196 *
197 * ### Creating a Reservation
198 * **Example:** Generated name, zero-slot ENTERPRISE
199 * ```typescript
200 * const slots = yield* GCP.BigQueryReservation.Reservation("Slots", {});
201 * ```
202 *
203 * **Example:** Explicit id, labels, and idle-slot isolation
204 * ```typescript
205 * const slots = yield* GCP.BigQueryReservation.Reservation("Slots", {
206 * reservationId: "analytics-prod",
207 * location: "us-central1",
208 * edition: "ENTERPRISE",
209 * slotCapacity: "0",
210 * ignoreIdleSlots: false,
211 * labels: { env: "prod" },
212 * });
213 * ```
214 *
215 * **Example:** STANDARD with autoscale
216 * ```typescript
217 * const slots = yield* GCP.BigQueryReservation.Reservation("Slots", {
218 * location: "US",
219 * edition: "STANDARD",
220 * ignoreIdleSlots: true,
221 * autoscale: { maxSlots: "50" },
222 * });
223 * ```
224 *
225 * @resource
226 * @category BigQueryReservation
227 */
228export const Reservation = Resource<Reservation>(
229 "GCP.BigQueryReservation.Reservation",
230);
231
232export class ReservationNotResolved extends Data.TaggedError(
233 "GCP.BigQueryReservation.ReservationNotResolved",
234)<{
235 name: string;
236}> {}
237
238const lastSegment = (value: string) => {
239 const trimmed = value.replace(/\/+$/, "");
240 const parts = trimmed.split("/");
241 return parts[parts.length - 1] || trimmed;
242};
243
244const normalizeLocation = (location: string) => {
245 const value = lastSegment(location);
246 const upper = value.toUpperCase();
247 if (upper === "US" || upper === "EU") return upper;
248 return value.toLowerCase();
249};
250
251const normalizeEdition = (edition: string | undefined) => {
252 const value = (edition ?? DEFAULT_EDITION).toUpperCase();
253 return value === "EDITION_UNSPECIFIED" ? DEFAULT_EDITION : value;
254};
255
256const slotCapacityOf = (value: string | undefined) =>
257 value === undefined || value === "" ? DEFAULT_SLOT_CAPACITY : value;
258
259const ignoreIdleSlotsOf = (value: boolean | undefined, edition: string) =>
260 value ?? edition === "STANDARD";
261
262const resourceName = (
263 project: string,
264 location: string,
265 reservationId: string,
266) => `projects/${project}/locations/${location}/reservations/${reservationId}`;
267
268const parentOf = (project: string, location: string) =>
269 `projects/${project}/locations/${location}`;
270
271const parseName = (name: string) => {
272 const parts = name.split("/").filter((part) => part.length > 0);
273 const reservationsAt = parts.lastIndexOf("reservations");
274 const locationsAt = parts.lastIndexOf("locations");
275 const projectsAt = parts.lastIndexOf("projects");
276 return {
277 project:
278 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
279 location:
280 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
281 reservationId:
282 reservationsAt >= 0 && parts[reservationsAt + 1]
283 ? parts[reservationsAt + 1]!
284 : lastSegment(name),
285 };
286};
287
288const userLabels = (
289 labels: Record<string, string | undefined> | null | undefined,
290): Record<string, string> => stripInternalLabels(tagRecord(labels));
291
292const toId = (
293 id: string,
294 reservationId: string | undefined,
295 existing?: string,
296) =>
297 Effect.gen(function* () {
298 if (reservationId !== undefined) return reservationId;
299 if (existing !== undefined) return existing;
300 const generated = yield* createPhysicalName({
301 id,
302 maxLength: MAX_NAME_LENGTH,
303 lowercase: true,
304 });
305 const prefixed = /^[a-z]/.test(generated) ? generated : `r${generated}`;
306 return prefixed.replace(/-+$/g, "").slice(0, MAX_NAME_LENGTH);
307 });
308
309const compact = <T extends Record<string, unknown>>(value: T): T =>
310 Object.fromEntries(
311 Object.entries(value).filter(([, item]) => item !== undefined),
312 ) as T;
313
314const jsonOf = (value: unknown) => JSON.stringify(value ?? null);
315
316const toAttrs = (current: bigqueryreservation.Reservation, project: string) => {
317 const name = current.name ?? "";
318 const parsed = parseName(name);
319 return {
320 name,
321 reservationId: parsed.reservationId,
322 project: parsed.project || project,
323 location: parsed.location,
324 labels: userLabels(current.labels),
325 slotCapacity: current.slotCapacity,
326 ignoreIdleSlots: current.ignoreIdleSlots === true,
327 edition: current.edition,
328 autoscale: current.autoscale,
329 concurrency: current.concurrency,
330 maxSlots: current.maxSlots,
331 scalingMode: current.scalingMode,
332 reservationGroup: current.reservationGroup,
333 secondaryLocation: current.secondaryLocation,
334 primaryLocation: current.primaryLocation,
335 originalPrimaryLocation: current.originalPrimaryLocation,
336 multiRegionAuxiliary: current.multiRegionAuxiliary === true,
337 schedulingPolicy: current.schedulingPolicy,
338 replicationStatus: current.replicationStatus,
339 creationTime: current.creationTime,
340 updateTime: current.updateTime,
341 };
342};
343
344const getByName = (name: string) =>
345 bigqueryreservation
346 .getProjectsLocationsReservations({ name })
347 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
348
349const listOwnedAt = (project: string, location: string) =>
350 bigqueryreservation.listProjectsLocationsReservations
351 .pages({
352 parent: parentOf(project, location),
353 pageSize: 1000,
354 })
355 .pipe(
356 Stream.flatMap((page) => Stream.fromIterable(page.reservations ?? [])),
357 Stream.filter((item) =>
358 Object.keys(item.labels ?? {}).some((key) =>
359 key.startsWith("alchemy-"),
360 ),
361 ),
362 Stream.map((item) => toAttrs(item, project)),
363 Stream.runCollect,
364 Effect.map((chunk) => Array.from(chunk)),
365 Effect.catchTag("NotFound", () => Effect.succeed([])),
366 );
367
368const desiredAutoscale = (
369 news: ReservationProps,
370): bigqueryreservation.Autoscale | undefined =>
371 news.autoscale === undefined
372 ? undefined
373 : compact({ maxSlots: news.autoscale.maxSlots });
374
375const desiredSchedulingPolicy = (
376 news: ReservationProps,
377): bigqueryreservation.SchedulingPolicy | undefined =>
378 news.schedulingPolicy === undefined
379 ? undefined
380 : compact({
381 maxSlots: news.schedulingPolicy.maxSlots,
382 concurrency: news.schedulingPolicy.concurrency,
383 });
384
385const toBody = (
386 news: ReservationProps,
387 labels: Record<string, string>,
388 extras: {
389 edition?: string;
390 slotCapacity: string;
391 ignoreIdleSlots: boolean;
392 },
393): bigqueryreservation.Reservation =>
394 compact({
395 slotCapacity: extras.slotCapacity,
396 ignoreIdleSlots: extras.ignoreIdleSlots,
397 edition: extras.edition,
398 labels,
399 autoscale: desiredAutoscale(news),
400 concurrency: news.concurrency,
401 maxSlots: news.maxSlots,
402 scalingMode: news.scalingMode,
403 reservationGroup: news.reservationGroup,
404 secondaryLocation: news.secondaryLocation,
405 multiRegionAuxiliary: news.multiRegionAuxiliary,
406 schedulingPolicy: desiredSchedulingPolicy(news),
407 });
408
409export const ReservationProvider = () =>
410 Provider.succeed(Reservation, {
411 stables: [
412 "name",
413 "reservationId",
414 "project",
415 "location",
416 "edition",
417 "creationTime",
418 ],
419
420 diff: Effect.fn(function* ({ news, olds, output }) {
421 if (!isResolved(news)) return undefined;
422 const env = yield* GcpEnvironment.current;
423
424 const previousId = olds?.reservationId ?? output?.reservationId;
425 const nextId = news.reservationId ?? previousId;
426 const previousLocation = normalizeLocation(
427 olds?.location ?? output?.location ?? env.region,
428 );
429 const nextLocation = normalizeLocation(
430 news.location ?? output?.location ?? env.region,
431 );
432 const previousEdition = normalizeEdition(
433 olds?.edition ?? output?.edition,
434 );
435 const nextEdition = normalizeEdition(news.edition ?? previousEdition);
436 const previousAuxiliary =
437 olds?.multiRegionAuxiliary ?? output?.multiRegionAuxiliary;
438 const nextAuxiliary =
439 news.multiRegionAuxiliary ?? previousAuxiliary ?? false;
440
441 const replace =
442 (previousId !== undefined &&
443 nextId !== undefined &&
444 nextId !== previousId) ||
445 previousLocation !== nextLocation ||
446 previousEdition !== nextEdition ||
447 (previousAuxiliary === true) !== (nextAuxiliary === true);
448
449 if (!replace) return undefined;
450 return {
451 action: "replace" as const,
452 deleteFirst:
453 previousLocation === nextLocation &&
454 previousId !== undefined &&
455 nextId === previousId,
456 };
457 }),
458
459 read: Effect.fn(function* ({ id, olds, output }) {
460 const env = yield* GcpEnvironment.current;
461 const reservationId = yield* toId(
462 id,
463 olds?.reservationId,
464 output?.reservationId,
465 );
466 const location = normalizeLocation(
467 olds?.location ?? output?.location ?? env.region,
468 );
469 const name =
470 output?.name ?? resourceName(env.project, location, reservationId);
471 const existing = yield* getByName(name);
472 if (existing === undefined) return undefined;
473 const attrs = toAttrs(existing, env.project);
474 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
475 ? attrs
476 : Unowned(attrs);
477 }),
478
479 list: () =>
480 Effect.gen(function* () {
481 const env = yield* GcpEnvironment.current;
482 const pages = yield* Effect.forEach(
483 Array.from(new Set([env.region, "US", "EU"])),
484 (location) => listOwnedAt(env.project, location),
485 { concurrency: 4 },
486 );
487 const byName = new Map<string, ReturnType<typeof toAttrs>>();
488 for (const item of pages.flat()) {
489 byName.set(item.name, item);
490 }
491 return Array.from(byName.values());
492 }),
493
494 reconcile: Effect.fn(function* ({ id, news, output }) {
495 const env = yield* GcpEnvironment.current;
496 const reservationId = yield* toId(
497 id,
498 news.reservationId,
499 output?.reservationId,
500 );
501 const location = normalizeLocation(
502 news.location ?? output?.location ?? env.region,
503 );
504 const name = resourceName(env.project, location, reservationId);
505 const parent = parentOf(env.project, location);
506 const desiredLabels = {
507 ...toLabels(news.labels),
508 ...(yield* createInternalLabels(id)),
509 };
510 const edition = normalizeEdition(news.edition ?? output?.edition);
511 const slotCapacity = slotCapacityOf(news.slotCapacity);
512 const ignoreIdleSlots = ignoreIdleSlotsOf(news.ignoreIdleSlots, edition);
513 const createBody = toBody(news, desiredLabels, {
514 edition,
515 slotCapacity,
516 ignoreIdleSlots,
517 });
518
519 let current = yield* getByName(output?.name ?? name);
520
521 if (current === undefined) {
522 const created = yield* bigqueryreservation
523 .createProjectsLocationsReservations({
524 parent,
525 reservationId,
526 body: createBody,
527 })
528 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
529 current = created ?? undefined;
530 }
531
532 if (current === undefined) {
533 return yield* new ReservationNotResolved({ name });
534 }
535
536 const observedLabels = tagRecord(current.labels);
537 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
538 const labelsChanged = upsert.length > 0 || removed.length > 0;
539 const slotCapacityChanged =
540 slotCapacityOf(current.slotCapacity) !== slotCapacity;
541 const ignoreIdleSlotsChanged =
542 (current.ignoreIdleSlots === true) !== ignoreIdleSlots;
543 const autoscaleChanged =
544 news.autoscale !== undefined &&
545 (current.autoscale?.maxSlots ?? "") !== (news.autoscale.maxSlots ?? "");
546 const concurrencyChanged =
547 news.concurrency !== undefined &&
548 (current.concurrency ?? "") !== news.concurrency;
549 const maxSlotsChanged =
550 news.maxSlots !== undefined &&
551 (current.maxSlots ?? "") !== news.maxSlots;
552 const scalingModeChanged =
553 news.scalingMode !== undefined &&
554 (current.scalingMode ?? "") !== news.scalingMode;
555 const reservationGroupChanged =
556 news.reservationGroup !== undefined &&
557 (current.reservationGroup ?? "") !== news.reservationGroup;
558 const secondaryLocationChanged =
559 news.secondaryLocation !== undefined &&
560 (current.secondaryLocation ?? "") !== news.secondaryLocation;
561 const schedulingPolicyChanged =
562 news.schedulingPolicy !== undefined &&
563 jsonOf(current.schedulingPolicy) !==
564 jsonOf(desiredSchedulingPolicy(news));
565
566 const updateMask = [
567 labelsChanged ? "labels" : undefined,
568 slotCapacityChanged ? "slotCapacity" : undefined,
569 ignoreIdleSlotsChanged ? "ignoreIdleSlots" : undefined,
570 autoscaleChanged ? "autoscale" : undefined,
571 concurrencyChanged ? "concurrency" : undefined,
572 maxSlotsChanged ? "maxSlots" : undefined,
573 scalingModeChanged ? "scalingMode" : undefined,
574 reservationGroupChanged ? "reservationGroup" : undefined,
575 secondaryLocationChanged ? "secondaryLocation" : undefined,
576 schedulingPolicyChanged ? "schedulingPolicy" : undefined,
577 ].filter((field): field is string => field !== undefined);
578
579 if (updateMask.length > 0) {
580 current = yield* bigqueryreservation.patchProjectsLocationsReservations(
581 {
582 name: current.name ?? name,
583 updateMask: updateMask.join(","),
584 body: toBody(news, desiredLabels, {
585 slotCapacity,
586 ignoreIdleSlots,
587 }),
588 },
589 );
590 }
591
592 return toAttrs(current, env.project);
593 }),
594
595 delete: Effect.fn(function* ({ output }) {
596 yield* bigqueryreservation
597 .deleteProjectsLocationsReservations({ name: output.name })
598 .pipe(Effect.catchTag("NotFound", () => Effect.void));
599 }),
600 });