1import * as spanner from "@distilled.cloud/gcp/spanner_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 configIdOf,
14 configNameOf,
15 DEFAULT_PARTITION_PROCESSING_UNITS,
16 displayNameOf,
17 instanceIdOf,
18 instanceName,
19 instancePartitionName,
20 listAlchemyInstances,
21 MAX_PARTITION_ID_LENGTH,
22 parentOwned,
23 parseResourceName,
24 retryConcurrentChanges,
25 toPhysicalId,
26 waitForOperation,
27} from "./operations.ts";
28
29export type PartitionAutoscalingLimits = {
30 /** Minimum nodes. Mutually exclusive with processing-unit limits. */
31 minNodes?: number;
32 /** Minimum processing units (multiples of 1000). */
33 minProcessingUnits?: number;
34 /** Maximum nodes. */
35 maxNodes?: number;
36 /** Maximum processing units (multiples of 1000). */
37 maxProcessingUnits?: number;
38};
39
40export type PartitionAutoscalingTargets = {
41 /** Target high-priority CPU percent (`10`–`90`). */
42 highPriorityCpuUtilizationPercent?: number;
43 /** Target total CPU percent (`10`–`90`). */
44 totalCpuUtilizationPercent?: number;
45 /** Target storage percent (`10`–`99`). */
46 storageUtilizationPercent?: number;
47};
48
49export type PartitionAutoscalingConfig = {
50 /** Min/max compute capacity. */
51 autoscalingLimits?: PartitionAutoscalingLimits;
52 /** CPU and storage targets. */
53 autoscalingTargets?: PartitionAutoscalingTargets;
54};
55
56export type InstancesInstancePartitionProps = {
57 /**
58 * Parent instance id or full name
59 * (`projects/{project}/instances/{instance}`). Immutable — changing
60 * it replaces the partition.
61 */
62 instance: string;
63 /**
64 * Instance partition id (the `{partition}` segment of
65 * `.../instancePartitions/{partition}`). If omitted, a unique name is
66 * generated. Must match `^[a-z][-a-z0-9]*[a-z0-9]$` (2–64
67 * characters). Immutable — changing it replaces the partition.
68 */
69 instancePartitionId?: string;
70 /**
71 * Instance configuration id (`regional-us-west1`) or full name.
72 * Typically a different config than the parent instance. Immutable —
73 * changing it replaces the partition.
74 */
75 config: string;
76 /**
77 * User-facing display name. Must be unique per project and 4–30
78 * characters. Defaults to a truncated partition id.
79 */
80 displayName?: string;
81 /**
82 * Processing units. Mutually exclusive with `nodeCount`. Ignored when
83 * autoscaling is enabled. Minimum is 1000 (1 node).
84 * @default 1000
85 */
86 processingUnits?: number;
87 /**
88 * Node count. Mutually exclusive with `processingUnits`. Ignored when
89 * autoscaling is enabled.
90 */
91 nodeCount?: number;
92 /**
93 * Autoscaling configuration. When set, `nodeCount` and
94 * `processingUnits` become output-only.
95 */
96 autoscalingConfig?: PartitionAutoscalingConfig;
97};
98
99export type InstancesInstancePartition = Resource<
100 "GCP.Spanner.InstancesInstancePartition",
101 InstancesInstancePartitionProps,
102 {
103 /** Full resource name `.../instancePartitions/{partition}`. */
104 name: string;
105 /** Instance partition id (last path segment). */
106 instancePartitionId: string;
107 /** Parent instance id. */
108 instanceId: string;
109 /** Project id. */
110 project: string;
111 /** Full instance config name. */
112 config: string;
113 /** User-facing display name. */
114 displayName: string | undefined;
115 /** Processing units currently allocated. */
116 processingUnits: number | undefined;
117 /** Nodes currently allocated. */
118 nodeCount: number | undefined;
119 /** Autoscaling configuration, if enabled. */
120 autoscalingConfig: PartitionAutoscalingConfig | undefined;
121 /** Current state (`CREATING`, `READY`). */
122 state: string | undefined;
123 /** RFC3339 creation timestamp. */
124 createTime: string | undefined;
125 /** RFC3339 last-update timestamp. */
126 updateTime: string | undefined;
127 /** Databases that reference this partition. */
128 referencingDatabases: string[];
129 /** Backups that reference this partition. */
130 referencingBackups: string[];
131 },
132 never,
133 Providers
134>;
135
136/**
137 * A Cloud Spanner instance partition for geo-partitioned data placement.
138 *
139 * Instance partitions have no labels field. Alchemy treats a partition
140 * as owned when its parent instance carries Alchemy labels, so `list` /
141 * `pnpm nuke:gcp` can find it. Geo-partitioning requires Enterprise Plus
142 * and typically a dual-region or multi-region parent instance. Changing
143 * `instancePartitionId`, `instance`, or `config` replaces the partition.
144 * Display name, compute capacity, and autoscaling update in place.
145 *
146 * ### Creating an Instance Partition
147 * **Example:** 1000 processing units in a second region
148 * ```typescript
149 * const partition = yield* GCP.Spanner.InstancesInstancePartition(
150 * "West",
151 * {
152 * instance: instance.instanceId,
153 * config: "regional-us-west1",
154 * processingUnits: 1000,
155 * },
156 * );
157 * ```
158 *
159 * **Example:** Explicit id and node count
160 * ```typescript
161 * const partition = yield* GCP.Spanner.InstancesInstancePartition(
162 * "West",
163 * {
164 * instance: instance.name,
165 * instancePartitionId: "west",
166 * config: "regional-us-west1",
167 * displayName: "west-partition",
168 * nodeCount: 1,
169 * },
170 * );
171 * ```
172 *
173 * @resource
174 * @category Spanner
175 */
176export const InstancesInstancePartition = Resource<InstancesInstancePartition>(
177 "GCP.Spanner.InstancesInstancePartition",
178);
179
180export class InstancePartitionNotResolved extends Data.TaggedError(
181 "GCP.Spanner.InstancePartitionNotResolved",
182)<{
183 name: string;
184}> {}
185
186export class InstancePartitionNotReady extends Data.TaggedError(
187 "GCP.Spanner.InstancePartitionNotReady",
188)<{
189 name: string;
190 state: string;
191}> {}
192
193export class InstancePartitionStillExists extends Data.TaggedError(
194 "GCP.Spanner.InstancePartitionStillExists",
195)<{
196 name: string;
197}> {}
198
199const toId = (
200 id: string,
201 instancePartitionId: string | undefined,
202 existing?: string,
203) => toPhysicalId(id, instancePartitionId, existing, MAX_PARTITION_ID_LENGTH);
204
205const autoscalingOf = (
206 config: spanner.AutoscalingConfig | undefined,
207): PartitionAutoscalingConfig | undefined => {
208 if (config === undefined) return undefined;
209 return {
210 autoscalingLimits: config.autoscalingLimits
211 ? {
212 minNodes: config.autoscalingLimits.minNodes,
213 minProcessingUnits: config.autoscalingLimits.minProcessingUnits,
214 maxNodes: config.autoscalingLimits.maxNodes,
215 maxProcessingUnits: config.autoscalingLimits.maxProcessingUnits,
216 }
217 : undefined,
218 autoscalingTargets: config.autoscalingTargets
219 ? {
220 highPriorityCpuUtilizationPercent:
221 config.autoscalingTargets.highPriorityCpuUtilizationPercent,
222 totalCpuUtilizationPercent:
223 config.autoscalingTargets.totalCpuUtilizationPercent,
224 storageUtilizationPercent:
225 config.autoscalingTargets.storageUtilizationPercent,
226 }
227 : undefined,
228 };
229};
230
231const autoscalingKey = (config: PartitionAutoscalingConfig | undefined) =>
232 JSON.stringify(config ?? null);
233
234const toAttrs = (
235 partition: spanner.InstancePartition,
236 project: string,
237): InstancesInstancePartition["Attributes"] => {
238 const name = partition.name ?? "";
239 const parsed = parseResourceName(name);
240 return {
241 name,
242 instancePartitionId: parsed.instancePartitionId,
243 instanceId: parsed.instanceId,
244 project: parsed.project || project,
245 config: partition.config ?? "",
246 displayName: partition.displayName,
247 processingUnits: partition.processingUnits,
248 nodeCount: partition.nodeCount,
249 autoscalingConfig: autoscalingOf(partition.autoscalingConfig),
250 state: partition.state,
251 createTime: partition.createTime,
252 updateTime: partition.updateTime,
253 referencingDatabases: partition.referencingDatabases ?? [],
254 referencingBackups: partition.referencingBackups ?? [],
255 };
256};
257
258const getByName = (name: string) =>
259 spanner
260 .getProjectsInstancesInstancePartitions({ name })
261 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
262
263const waitUntilExists = (name: string) =>
264 getByName(name).pipe(
265 Effect.flatMap((partition) =>
266 partition
267 ? Effect.succeed(partition)
268 : Effect.fail(new InstancePartitionNotResolved({ name })),
269 ),
270 Effect.retry({
271 while: (error) =>
272 error._tag === "GCP.Spanner.InstancePartitionNotResolved",
273 times: 8,
274 schedule: Schedule.spaced("2 seconds"),
275 }),
276 );
277
278const waitUntilReady = (name: string) =>
279 getByName(name).pipe(
280 Effect.filterOrFail(
281 (partition): partition is spanner.InstancePartition =>
282 partition !== undefined,
283 () => new InstancePartitionNotResolved({ name }),
284 ),
285 Effect.filterOrFail(
286 (partition) => (partition.state ?? "STATE_UNSPECIFIED") === "READY",
287 (partition) =>
288 new InstancePartitionNotReady({
289 name,
290 state: partition.state ?? "STATE_UNSPECIFIED",
291 }),
292 ),
293 Effect.retry({
294 while: (error) =>
295 error._tag === "GCP.Spanner.InstancePartitionNotReady" ||
296 error._tag === "GCP.Spanner.InstancePartitionNotResolved",
297 times: 10,
298 schedule: Schedule.spaced("8 seconds"),
299 }),
300 );
301
302const waitUntilGone = (name: string) =>
303 getByName(name).pipe(
304 Effect.flatMap((partition) =>
305 partition === undefined
306 ? Effect.void
307 : Effect.fail(new InstancePartitionStillExists({ name })),
308 ),
309 Effect.retry({
310 while: (error) =>
311 error._tag === "GCP.Spanner.InstancePartitionStillExists",
312 times: 10,
313 schedule: Schedule.spaced("8 seconds"),
314 }),
315 );
316
317const toCreatePartition = (
318 name: string,
319 news: InstancesInstancePartitionProps,
320 project: string,
321 region: string,
322 displayName: string,
323): spanner.InstancePartition => {
324 const body: spanner.InstancePartition = {
325 name,
326 config: configNameOf(project, news.config, region),
327 displayName,
328 };
329 if (news.autoscalingConfig !== undefined) {
330 body.autoscalingConfig = news.autoscalingConfig;
331 } else if (news.nodeCount !== undefined) {
332 body.nodeCount = news.nodeCount;
333 } else {
334 body.processingUnits =
335 news.processingUnits ?? DEFAULT_PARTITION_PROCESSING_UNITS;
336 }
337 return body;
338};
339
340export const InstancesInstancePartitionProvider = () =>
341 Provider.succeed(InstancesInstancePartition, {
342 stables: [
343 "name",
344 "instancePartitionId",
345 "instanceId",
346 "project",
347 "config",
348 "createTime",
349 ],
350
351 diff: Effect.fn(function* ({ news, olds, output }) {
352 if (!isResolved(news)) return undefined;
353 const env = yield* GcpEnvironment.current;
354
355 const previousId =
356 olds?.instancePartitionId ?? output?.instancePartitionId;
357 const nextId = news.instancePartitionId ?? previousId;
358 const previousInstance = instanceIdOf(
359 olds?.instance ?? output?.instanceId ?? "",
360 );
361 const nextInstance = instanceIdOf(news.instance);
362 const previousConfig = configIdOf(
363 olds?.config ?? output?.config,
364 env.region,
365 );
366 const nextConfig = configIdOf(
367 news.config ?? olds?.config ?? output?.config,
368 env.region,
369 );
370
371 const replace =
372 (previousId !== undefined &&
373 nextId !== undefined &&
374 previousId !== nextId) ||
375 (previousInstance.length > 0 && previousInstance !== nextInstance) ||
376 previousConfig !== nextConfig;
377
378 if (!replace) return undefined;
379 return {
380 action: "replace" as const,
381 deleteFirst:
382 previousConfig !== nextConfig ||
383 (previousId !== undefined && nextId === previousId),
384 };
385 }),
386
387 read: Effect.fn(function* ({ id, olds, output }) {
388 const env = yield* GcpEnvironment.current;
389 const instancePartitionId = yield* toId(
390 id,
391 olds?.instancePartitionId,
392 output?.instancePartitionId,
393 );
394 const instanceId = instanceIdOf(
395 olds?.instance ?? output?.instanceId ?? "",
396 );
397 if (instanceId.length === 0) return undefined;
398 const name =
399 output?.name ??
400 instancePartitionName(env.project, instanceId, instancePartitionId);
401 const existing = yield* getByName(name);
402 if (existing === undefined) return undefined;
403 const attrs = toAttrs(existing, env.project);
404 return (yield* parentOwned(instanceName(attrs.project, attrs.instanceId)))
405 ? attrs
406 : Unowned(attrs);
407 }),
408
409 list: () =>
410 Effect.gen(function* () {
411 const env = yield* GcpEnvironment.current;
412 const instances = yield* listAlchemyInstances(env.project);
413 const pages = yield* Effect.forEach(
414 instances,
415 (instance) => {
416 const parent = instance.name;
417 if (parent === undefined || parent.length === 0) {
418 return Effect.succeed(
419 [] as InstancesInstancePartition["Attributes"][],
420 );
421 }
422 return spanner.listProjectsInstancesInstancePartitions
423 .pages({
424 parent,
425 pageSize: 1000,
426 })
427 .pipe(
428 Stream.flatMap((page) =>
429 Stream.fromIterable(page.instancePartitions ?? []),
430 ),
431 Stream.map((partition) => toAttrs(partition, env.project)),
432 Stream.runCollect,
433 Effect.map((chunk) => Array.from(chunk)),
434 Effect.catchTag("NotFound", () =>
435 Effect.succeed(
436 [] as InstancesInstancePartition["Attributes"][],
437 ),
438 ),
439 );
440 },
441 { concurrency: 4 },
442 );
443 return pages.flat();
444 }),
445
446 reconcile: Effect.fn(function* ({ id, news, output }) {
447 const env = yield* GcpEnvironment.current;
448 const instanceId = instanceIdOf(news.instance);
449 const instancePartitionId = yield* toId(
450 id,
451 news.instancePartitionId,
452 output?.instancePartitionId,
453 );
454 const name = instancePartitionName(
455 env.project,
456 instanceId,
457 instancePartitionId,
458 );
459 const displayName = displayNameOf(instancePartitionId, news.displayName);
460
461 let current = yield* getByName(output?.name ?? name);
462
463 if (current === undefined) {
464 const created = yield* spanner
465 .createProjectsInstancesInstancePartitions({
466 parent: instanceName(env.project, instanceId),
467 body: {
468 instancePartitionId,
469 instancePartition: toCreatePartition(
470 name,
471 news,
472 env.project,
473 env.region,
474 displayName,
475 ),
476 },
477 })
478 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
479 if (created !== undefined) {
480 yield* waitForOperation(created, { alreadyExistsOk: true });
481 }
482 current = yield* waitUntilExists(name);
483 }
484
485 if ((current.state ?? "") !== "READY") {
486 current = yield* waitUntilReady(name);
487 }
488
489 const displayNameChanged = (current.displayName ?? "") !== displayName;
490 const autoscalingChanged =
491 news.autoscalingConfig !== undefined &&
492 autoscalingKey(autoscalingOf(current.autoscalingConfig)) !==
493 autoscalingKey(news.autoscalingConfig);
494 const processingUnitsChanged =
495 news.autoscalingConfig === undefined &&
496 news.nodeCount === undefined &&
497 news.processingUnits !== undefined &&
498 (current.processingUnits ?? 0) !== news.processingUnits;
499 const nodeCountChanged =
500 news.autoscalingConfig === undefined &&
501 news.nodeCount !== undefined &&
502 (current.nodeCount ?? 0) !== news.nodeCount;
503
504 if (
505 displayNameChanged ||
506 autoscalingChanged ||
507 processingUnitsChanged ||
508 nodeCountChanged
509 ) {
510 const fieldMask = [
511 displayNameChanged ? "display_name" : undefined,
512 autoscalingChanged ? "autoscaling_config" : undefined,
513 processingUnitsChanged ? "processing_units" : undefined,
514 nodeCountChanged ? "node_count" : undefined,
515 ].filter((field): field is string => field !== undefined);
516
517 const patchBody: spanner.InstancePartition = {
518 name,
519 displayName,
520 };
521 if (autoscalingChanged) {
522 patchBody.autoscalingConfig = news.autoscalingConfig;
523 }
524 if (processingUnitsChanged) {
525 patchBody.processingUnits = news.processingUnits;
526 }
527 if (nodeCountChanged) {
528 patchBody.nodeCount = news.nodeCount;
529 }
530
531 const patched = yield* retryConcurrentChanges(
532 spanner.patchProjectsInstancesInstancePartitions({
533 name,
534 body: {
535 instancePartition: patchBody,
536 fieldMask: fieldMask.join(","),
537 },
538 }),
539 );
540 yield* waitForOperation(patched);
541 current = yield* waitUntilReady(name);
542 }
543
544 return toAttrs(current, env.project);
545 }),
546
547 delete: Effect.fn(function* ({ output }) {
548 yield* spanner
549 .deleteProjectsInstancesInstancePartitions({ name: output.name })
550 .pipe(
551 Effect.catchTag("NotFound", () => Effect.void),
552 Effect.retry({
553 while: (error) => error._tag === "Conflict",
554 times: 8,
555 schedule: Schedule.spaced("5 seconds"),
556 }),
557 );
558 yield* waitUntilGone(output.name);
559 }),
560 });