| 1 | import * as bigtable from "@distilled.cloud/gcp/bigtableadmin_v2"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { GcpEnvironment } from "../Environment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | import { |
| 12 | clusterLocation, |
| 13 | clusterName, |
| 14 | DEFAULT_SERVE_NODES, |
| 15 | DEFAULT_ZONE, |
| 16 | lastSegment, |
| 17 | listAlchemyInstances, |
| 18 | MAX_CLUSTER_ID_LENGTH, |
| 19 | MIN_CLUSTER_ID_LENGTH, |
| 20 | parentOwned, |
| 21 | parseResourceName, |
| 22 | toPhysicalId, |
| 23 | waitForOperation, |
| 24 | zoneOf, |
| 25 | collectPages, |
| 26 | } from "./operations.ts"; |
| 27 | |
| 28 | const DEFAULT_STORAGE_TYPE = "SSD"; |
| 29 | |
| 30 | export type AutoscalingLimits = { |
| 31 | /** Minimum nodes the autoscaler can scale down to. */ |
| 32 | minServeNodes?: number; |
| 33 | /** Maximum nodes the autoscaler can scale up to. */ |
| 34 | maxServeNodes?: number; |
| 35 | }; |
| 36 | |
| 37 | export type AutoscalingTargets = { |
| 38 | /** |
| 39 | * Target CPU utilization percent (`10`–`80`). |
| 40 | */ |
| 41 | cpuUtilizationPercent?: number; |
| 42 | /** |
| 43 | * Target storage utilization in GiB per node. SSD default `2560`; |
| 44 | * HDD default `8192`. |
| 45 | */ |
| 46 | storageUtilizationGibPerNode?: number; |
| 47 | }; |
| 48 | |
| 49 | export type ClusterAutoscalingConfig = { |
| 50 | /** Minimum and maximum node counts. */ |
| 51 | autoscalingLimits?: AutoscalingLimits; |
| 52 | /** CPU and storage utilization targets. */ |
| 53 | autoscalingTargets?: AutoscalingTargets; |
| 54 | }; |
| 55 | |
| 56 | export type ClusterConfig = { |
| 57 | /** Autoscaling configuration. Mutually exclusive with `serveNodes`. */ |
| 58 | clusterAutoscalingConfig?: ClusterAutoscalingConfig; |
| 59 | }; |
| 60 | |
| 61 | export type EncryptionConfig = { |
| 62 | /** |
| 63 | * Cloud KMS key |
| 64 | * `projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`. |
| 65 | * Must be in the same region as the cluster. Immutable — changing it |
| 66 | * replaces the cluster. |
| 67 | */ |
| 68 | kmsKeyName?: string; |
| 69 | }; |
| 70 | |
| 71 | export type ClusterResourceProps = { |
| 72 | /** |
| 73 | * Parent instance id (the `{instance}` segment of |
| 74 | * `projects/{project}/instances/{instance}`). Full resource names are |
| 75 | * accepted and reduced to the last path segment. Immutable — changing |
| 76 | * it replaces the cluster. |
| 77 | */ |
| 78 | instance: string; |
| 79 | /** |
| 80 | * Cluster id (the `{cluster}` segment of |
| 81 | * `projects/{project}/instances/{instance}/clusters/{cluster}`). If |
| 82 | * omitted, a unique name is generated from the stack, stage, and |
| 83 | * logical id. Must be 6-30 characters and match |
| 84 | * `[a-z][-a-z0-9]*`. Immutable — changing it replaces the cluster. |
| 85 | */ |
| 86 | clusterId?: string; |
| 87 | /** |
| 88 | * Zone where nodes and storage reside (e.g. `us-central1-b`). Full |
| 89 | * location names `projects/{project}/locations/{zone}` are accepted. |
| 90 | * Immutable — changing it replaces the cluster. |
| 91 | * @default "us-central1-b" |
| 92 | */ |
| 93 | location?: string; |
| 94 | /** |
| 95 | * Storage type used by this cluster (`SSD` or `HDD`). Immutable — |
| 96 | * changing it replaces the cluster. |
| 97 | * @default "SSD" |
| 98 | */ |
| 99 | defaultStorageType?: bigtable.ClusterDefaultStorageTypeEnum | (string & {}); |
| 100 | /** |
| 101 | * Manual node count. Mutually exclusive with |
| 102 | * `clusterConfig.clusterAutoscalingConfig`. Ignored while |
| 103 | * autoscaling is enabled. |
| 104 | * @default 1 |
| 105 | */ |
| 106 | serveNodes?: number; |
| 107 | /** |
| 108 | * Node scaling factor. Immutable — changing it replaces the cluster. |
| 109 | */ |
| 110 | nodeScalingFactor?: bigtable.ClusterNodeScalingFactorEnum | (string & {}); |
| 111 | /** |
| 112 | * Customer-managed encryption. Immutable — changing it replaces the |
| 113 | * cluster. |
| 114 | */ |
| 115 | encryptionConfig?: EncryptionConfig; |
| 116 | /** |
| 117 | * Cluster configuration. Set `clusterAutoscalingConfig` to enable |
| 118 | * autoscaling (and omit `serveNodes`). |
| 119 | */ |
| 120 | clusterConfig?: ClusterConfig; |
| 121 | }; |
| 122 | |
| 123 | export type Cluster = Resource< |
| 124 | "GCP.Bigtable.Cluster", |
| 125 | ClusterResourceProps, |
| 126 | { |
| 127 | /** Full resource name `projects/{project}/instances/{instance}/clusters/{cluster}`. */ |
| 128 | name: string; |
| 129 | /** Cluster id (last path segment). */ |
| 130 | clusterId: string; |
| 131 | /** Parent instance resource name. */ |
| 132 | instance: string; |
| 133 | /** Parent instance id. */ |
| 134 | instanceId: string; |
| 135 | /** Project id. */ |
| 136 | project: string; |
| 137 | /** Zone id (`us-central1-b`, …). */ |
| 138 | location: string; |
| 139 | /** Storage type (`SSD`, `HDD`). */ |
| 140 | defaultStorageType: string | undefined; |
| 141 | /** Current node count. */ |
| 142 | serveNodes: number | undefined; |
| 143 | /** Node scaling factor. */ |
| 144 | nodeScalingFactor: string | undefined; |
| 145 | /** CMEK configuration, if any. */ |
| 146 | encryptionConfig: EncryptionConfig | undefined; |
| 147 | /** Cluster configuration currently applied. */ |
| 148 | clusterConfig: ClusterConfig | undefined; |
| 149 | /** Server-reported state (`READY`, `CREATING`, `RESIZING`, …). */ |
| 150 | state: string | undefined; |
| 151 | }, |
| 152 | never, |
| 153 | Providers |
| 154 | >; |
| 155 | |
| 156 | /** |
| 157 | * A Cloud Bigtable cluster — a resizable group of nodes in one zone |
| 158 | * that serves every table in the parent instance. |
| 159 | * |
| 160 | * An instance always has at least one cluster (created with the |
| 161 | * instance). Use this resource to add additional clusters or to manage |
| 162 | * an existing cluster's node count and autoscaling. You cannot delete |
| 163 | * the last cluster in an instance. |
| 164 | * |
| 165 | * Changing `clusterId`, `instance`, `location`, `defaultStorageType`, |
| 166 | * `nodeScalingFactor`, or `encryptionConfig.kmsKeyName` replaces the |
| 167 | * cluster. `serveNodes` and `clusterConfig.clusterAutoscalingConfig` |
| 168 | * update in place. |
| 169 | * |
| 170 | * Clusters have no labels field. `list` enumerates clusters on |
| 171 | * alchemy-labeled instances so `pnpm nuke:gcp` can find leaked rows. |
| 172 | * |
| 173 | * Provisioning typically takes several minutes. |
| 174 | * |
| 175 | * ### Creating a Cluster |
| 176 | * **Example:** Additional cluster on an existing instance |
| 177 | * ```typescript |
| 178 | * const replica = yield* GCP.Bigtable.Cluster("Replica", { |
| 179 | * instance: instance.instanceId, |
| 180 | * location: "us-central1-b", |
| 181 | * serveNodes: 1, |
| 182 | * }); |
| 183 | * ``` |
| 184 | * |
| 185 | * **Example:** Explicit id and HDD storage |
| 186 | * ```typescript |
| 187 | * const replica = yield* GCP.Bigtable.Cluster("Replica", { |
| 188 | * instance: "app-instance", |
| 189 | * clusterId: "app-replica", |
| 190 | * location: "us-central1-c", |
| 191 | * defaultStorageType: "HDD", |
| 192 | * serveNodes: 1, |
| 193 | * }); |
| 194 | * ``` |
| 195 | * |
| 196 | * ### Autoscaling |
| 197 | * **Example:** Autoscaling between 1 and 3 nodes |
| 198 | * ```typescript |
| 199 | * const replica = yield* GCP.Bigtable.Cluster("Replica", { |
| 200 | * instance: "app-instance", |
| 201 | * location: "us-central1-f", |
| 202 | * clusterConfig: { |
| 203 | * clusterAutoscalingConfig: { |
| 204 | * autoscalingLimits: { minServeNodes: 1, maxServeNodes: 3 }, |
| 205 | * autoscalingTargets: { cpuUtilizationPercent: 50 }, |
| 206 | * }, |
| 207 | * }, |
| 208 | * }); |
| 209 | * ``` |
| 210 | * |
| 211 | * @resource |
| 212 | * @category Bigtable |
| 213 | */ |
| 214 | export const Cluster = Resource<Cluster>("GCP.Bigtable.Cluster"); |
| 215 | |
| 216 | export class ClusterNotResolved extends Data.TaggedError( |
| 217 | "GCP.Bigtable.ClusterNotResolved", |
| 218 | )<{ |
| 219 | name: string; |
| 220 | }> {} |
| 221 | |
| 222 | export class ClusterNotReady extends Data.TaggedError( |
| 223 | "GCP.Bigtable.ClusterNotReady", |
| 224 | )<{ |
| 225 | name: string; |
| 226 | state: string; |
| 227 | }> {} |
| 228 | |
| 229 | export class ClusterStillExists extends Data.TaggedError( |
| 230 | "GCP.Bigtable.ClusterStillExists", |
| 231 | )<{ |
| 232 | name: string; |
| 233 | }> {} |
| 234 | |
| 235 | const instanceIdOf = (value: string) => lastSegment(value); |
| 236 | |
| 237 | const normalizeStorage = (storage: string | undefined) => { |
| 238 | const value = (storage ?? DEFAULT_STORAGE_TYPE).toUpperCase(); |
| 239 | return value === "STORAGE_TYPE_UNSPECIFIED" ? DEFAULT_STORAGE_TYPE : value; |
| 240 | }; |
| 241 | |
| 242 | const normalizeScaling = (factor: string | undefined) => |
| 243 | (factor ?? "").toUpperCase(); |
| 244 | |
| 245 | const encryptionOf = ( |
| 246 | config: bigtable.EncryptionConfig | EncryptionConfig | undefined, |
| 247 | ): EncryptionConfig | undefined => { |
| 248 | const kmsKeyName = config?.kmsKeyName; |
| 249 | if (kmsKeyName === undefined || kmsKeyName.length === 0) return undefined; |
| 250 | return { kmsKeyName }; |
| 251 | }; |
| 252 | |
| 253 | const encryptionKey = ( |
| 254 | config: bigtable.EncryptionConfig | EncryptionConfig | undefined, |
| 255 | ) => encryptionOf(config)?.kmsKeyName ?? ""; |
| 256 | |
| 257 | const autoscalingOf = ( |
| 258 | config: |
| 259 | | bigtable.ClusterAutoscalingConfig |
| 260 | | ClusterAutoscalingConfig |
| 261 | | undefined, |
| 262 | ): ClusterAutoscalingConfig | undefined => { |
| 263 | if (config === undefined) return undefined; |
| 264 | const limits = config.autoscalingLimits; |
| 265 | const targets = config.autoscalingTargets; |
| 266 | if (limits === undefined && targets === undefined) return undefined; |
| 267 | return { |
| 268 | autoscalingLimits: limits |
| 269 | ? { |
| 270 | minServeNodes: limits.minServeNodes, |
| 271 | maxServeNodes: limits.maxServeNodes, |
| 272 | } |
| 273 | : undefined, |
| 274 | autoscalingTargets: targets |
| 275 | ? { |
| 276 | cpuUtilizationPercent: targets.cpuUtilizationPercent, |
| 277 | storageUtilizationGibPerNode: targets.storageUtilizationGibPerNode, |
| 278 | } |
| 279 | : undefined, |
| 280 | }; |
| 281 | }; |
| 282 | |
| 283 | const clusterConfigOf = ( |
| 284 | config: bigtable.ClusterConfig | ClusterConfig | undefined, |
| 285 | ): ClusterConfig | undefined => { |
| 286 | const autoscaling = autoscalingOf(config?.clusterAutoscalingConfig); |
| 287 | if (autoscaling === undefined) return undefined; |
| 288 | return { clusterAutoscalingConfig: autoscaling }; |
| 289 | }; |
| 290 | |
| 291 | const autoscalingKey = ( |
| 292 | config: bigtable.ClusterConfig | ClusterConfig | undefined, |
| 293 | ) => { |
| 294 | const autoscaling = clusterConfigOf(config)?.clusterAutoscalingConfig; |
| 295 | return JSON.stringify({ |
| 296 | minServeNodes: autoscaling?.autoscalingLimits?.minServeNodes ?? null, |
| 297 | maxServeNodes: autoscaling?.autoscalingLimits?.maxServeNodes ?? null, |
| 298 | cpuUtilizationPercent: |
| 299 | autoscaling?.autoscalingTargets?.cpuUtilizationPercent ?? null, |
| 300 | storageUtilizationGibPerNode: |
| 301 | autoscaling?.autoscalingTargets?.storageUtilizationGibPerNode ?? null, |
| 302 | }); |
| 303 | }; |
| 304 | |
| 305 | const toAttrs = (cluster: bigtable.Cluster, project: string) => { |
| 306 | const name = cluster.name ?? ""; |
| 307 | const parsed = parseResourceName(name); |
| 308 | return { |
| 309 | name, |
| 310 | clusterId: parsed.clusterId, |
| 311 | instance: parsed.instance, |
| 312 | instanceId: parsed.instanceId, |
| 313 | project: parsed.project || project, |
| 314 | location: zoneOf(cluster.location || DEFAULT_ZONE), |
| 315 | defaultStorageType: cluster.defaultStorageType, |
| 316 | serveNodes: cluster.serveNodes, |
| 317 | nodeScalingFactor: cluster.nodeScalingFactor, |
| 318 | encryptionConfig: encryptionOf(cluster.encryptionConfig), |
| 319 | clusterConfig: clusterConfigOf(cluster.clusterConfig), |
| 320 | state: cluster.state, |
| 321 | }; |
| 322 | }; |
| 323 | |
| 324 | const getByName = (name: string) => |
| 325 | bigtable |
| 326 | .getProjectsInstancesClusters({ name }) |
| 327 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 328 | |
| 329 | const isBusy = (state: string | undefined) => |
| 330 | state === "CREATING" || |
| 331 | state === "RESIZING" || |
| 332 | state === "STATE_NOT_KNOWN" || |
| 333 | state === undefined; |
| 334 | |
| 335 | const waitUntilExists = (name: string) => |
| 336 | getByName(name).pipe( |
| 337 | Effect.flatMap((cluster) => |
| 338 | cluster |
| 339 | ? Effect.succeed(cluster) |
| 340 | : Effect.fail(new ClusterNotResolved({ name })), |
| 341 | ), |
| 342 | Effect.retry({ |
| 343 | while: (error) => error._tag === "GCP.Bigtable.ClusterNotResolved", |
| 344 | times: 8, |
| 345 | schedule: Schedule.spaced("1 second"), |
| 346 | }), |
| 347 | ); |
| 348 | |
| 349 | const waitUntilReady = (name: string) => |
| 350 | getByName(name).pipe( |
| 351 | Effect.filterOrFail( |
| 352 | (cluster): cluster is bigtable.Cluster => cluster !== undefined, |
| 353 | () => new ClusterNotResolved({ name }), |
| 354 | ), |
| 355 | Effect.filterOrFail( |
| 356 | (cluster) => !isBusy(cluster.state), |
| 357 | (cluster) => |
| 358 | new ClusterNotReady({ |
| 359 | name, |
| 360 | state: cluster.state ?? "STATE_NOT_KNOWN", |
| 361 | }), |
| 362 | ), |
| 363 | Effect.retry({ |
| 364 | while: (error) => |
| 365 | error._tag === "GCP.Bigtable.ClusterNotReady" || |
| 366 | error._tag === "GCP.Bigtable.ClusterNotResolved", |
| 367 | times: 10, |
| 368 | schedule: Schedule.spaced("8 seconds"), |
| 369 | }), |
| 370 | ); |
| 371 | |
| 372 | const waitUntilGone = (name: string) => |
| 373 | getByName(name).pipe( |
| 374 | Effect.flatMap((cluster) => |
| 375 | cluster === undefined |
| 376 | ? Effect.void |
| 377 | : Effect.fail(new ClusterStillExists({ name })), |
| 378 | ), |
| 379 | Effect.retry({ |
| 380 | while: (error) => error._tag === "GCP.Bigtable.ClusterStillExists", |
| 381 | times: 10, |
| 382 | schedule: Schedule.spaced("8 seconds"), |
| 383 | }), |
| 384 | ); |
| 385 | |
| 386 | const toCreateBody = ( |
| 387 | news: ClusterResourceProps, |
| 388 | project: string, |
| 389 | zone: string, |
| 390 | storage: string, |
| 391 | ): bigtable.Cluster => { |
| 392 | const autoscaling = clusterConfigOf(news.clusterConfig); |
| 393 | return { |
| 394 | location: clusterLocation(project, zone), |
| 395 | defaultStorageType: storage, |
| 396 | nodeScalingFactor: news.nodeScalingFactor, |
| 397 | encryptionConfig: news.encryptionConfig, |
| 398 | serveNodes: |
| 399 | autoscaling === undefined |
| 400 | ? (news.serveNodes ?? DEFAULT_SERVE_NODES) |
| 401 | : undefined, |
| 402 | clusterConfig: autoscaling, |
| 403 | }; |
| 404 | }; |
| 405 | |
| 406 | export const ClusterProvider = () => |
| 407 | Provider.succeed(Cluster, { |
| 408 | stables: [ |
| 409 | "name", |
| 410 | "clusterId", |
| 411 | "instance", |
| 412 | "instanceId", |
| 413 | "project", |
| 414 | "location", |
| 415 | ], |
| 416 | |
| 417 | diff: Effect.fn(function* ({ news, olds, output }) { |
| 418 | if (!isResolved(news)) return undefined; |
| 419 | |
| 420 | const previousId = olds?.clusterId ?? output?.clusterId; |
| 421 | const nextId = news.clusterId ?? previousId; |
| 422 | const previousInstance = instanceIdOf( |
| 423 | olds?.instance ?? output?.instance ?? output?.instanceId ?? "", |
| 424 | ); |
| 425 | const nextInstance = instanceIdOf(news.instance); |
| 426 | const previousZone = zoneOf(olds?.location ?? output?.location); |
| 427 | const nextZone = zoneOf(news.location ?? output?.location); |
| 428 | const previousStorage = normalizeStorage( |
| 429 | olds?.defaultStorageType ?? output?.defaultStorageType, |
| 430 | ); |
| 431 | const nextStorage = normalizeStorage( |
| 432 | news.defaultStorageType ?? output?.defaultStorageType, |
| 433 | ); |
| 434 | const previousScaling = normalizeScaling( |
| 435 | olds?.nodeScalingFactor ?? output?.nodeScalingFactor, |
| 436 | ); |
| 437 | const nextScaling = normalizeScaling( |
| 438 | news.nodeScalingFactor ?? output?.nodeScalingFactor, |
| 439 | ); |
| 440 | const previousCmek = encryptionKey( |
| 441 | olds?.encryptionConfig ?? output?.encryptionConfig, |
| 442 | ); |
| 443 | const nextCmek = encryptionKey( |
| 444 | news.encryptionConfig ?? output?.encryptionConfig, |
| 445 | ); |
| 446 | |
| 447 | const replace = |
| 448 | (previousId !== undefined && |
| 449 | nextId !== undefined && |
| 450 | nextId !== previousId) || |
| 451 | (previousInstance.length > 0 && |
| 452 | nextInstance.length > 0 && |
| 453 | previousInstance !== nextInstance) || |
| 454 | previousZone !== nextZone || |
| 455 | previousStorage !== nextStorage || |
| 456 | (previousScaling.length > 0 && |
| 457 | nextScaling.length > 0 && |
| 458 | previousScaling !== nextScaling) || |
| 459 | previousCmek !== nextCmek; |
| 460 | |
| 461 | if (!replace) return undefined; |
| 462 | return { |
| 463 | action: "replace" as const, |
| 464 | deleteFirst: |
| 465 | previousInstance === nextInstance && |
| 466 | previousId !== undefined && |
| 467 | nextId === previousId, |
| 468 | }; |
| 469 | }), |
| 470 | |
| 471 | read: Effect.fn(function* ({ id, olds, output }) { |
| 472 | const env = yield* GcpEnvironment.current; |
| 473 | const instanceId = instanceIdOf( |
| 474 | olds?.instance ?? output?.instance ?? output?.instanceId ?? "", |
| 475 | ); |
| 476 | if (instanceId.length === 0) return undefined; |
| 477 | const clusterId = yield* toPhysicalId( |
| 478 | id, |
| 479 | olds?.clusterId, |
| 480 | output?.clusterId, |
| 481 | MAX_CLUSTER_ID_LENGTH, |
| 482 | MIN_CLUSTER_ID_LENGTH, |
| 483 | ); |
| 484 | const name = |
| 485 | output?.name ?? clusterName(env.project, instanceId, clusterId); |
| 486 | const existing = yield* getByName(name); |
| 487 | if (existing === undefined) return undefined; |
| 488 | const attrs = toAttrs(existing, env.project); |
| 489 | return (yield* parentOwned(attrs.instance)) ? attrs : Unowned(attrs); |
| 490 | }), |
| 491 | |
| 492 | list: () => |
| 493 | Effect.gen(function* () { |
| 494 | const env = yield* GcpEnvironment.current; |
| 495 | const instances = yield* listAlchemyInstances(env.project); |
| 496 | const labeled = new Set( |
| 497 | instances |
| 498 | .map((instance) => instance.name ?? "") |
| 499 | .filter((name) => name.length > 0), |
| 500 | ); |
| 501 | if (labeled.size === 0) return []; |
| 502 | const clusters = yield* collectPages( |
| 503 | bigtable.listProjectsInstancesClusters.pages({ |
| 504 | parent: `projects/${env.project}/instances/-`, |
| 505 | }), |
| 506 | (page) => page.clusters, |
| 507 | ); |
| 508 | return clusters |
| 509 | .filter((cluster) => |
| 510 | labeled.has(parseResourceName(cluster.name ?? "").instance), |
| 511 | ) |
| 512 | .map((cluster) => toAttrs(cluster, env.project)); |
| 513 | }), |
| 514 | |
| 515 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 516 | const env = yield* GcpEnvironment.current; |
| 517 | const instanceId = instanceIdOf(news.instance); |
| 518 | const clusterId = yield* toPhysicalId( |
| 519 | id, |
| 520 | news.clusterId, |
| 521 | output?.clusterId, |
| 522 | MAX_CLUSTER_ID_LENGTH, |
| 523 | MIN_CLUSTER_ID_LENGTH, |
| 524 | ); |
| 525 | const zone = zoneOf(news.location ?? output?.location); |
| 526 | const storage = normalizeStorage(news.defaultStorageType); |
| 527 | const name = clusterName(env.project, instanceId, clusterId); |
| 528 | const desiredAutoscaling = clusterConfigOf(news.clusterConfig); |
| 529 | |
| 530 | let current = yield* getByName(output?.name ?? name); |
| 531 | |
| 532 | if (current === undefined) { |
| 533 | const created = yield* bigtable |
| 534 | .createProjectsInstancesClusters({ |
| 535 | parent: `projects/${env.project}/instances/${instanceId}`, |
| 536 | clusterId, |
| 537 | body: toCreateBody(news, env.project, zone, storage), |
| 538 | }) |
| 539 | .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined))); |
| 540 | if (created !== undefined) { |
| 541 | yield* waitForOperation(created, { alreadyExistsOk: true }); |
| 542 | } |
| 543 | current = yield* waitUntilExists(name); |
| 544 | } |
| 545 | |
| 546 | if (isBusy(current.state)) { |
| 547 | current = yield* waitUntilReady(name); |
| 548 | } |
| 549 | |
| 550 | const observedAutoscaling = clusterConfigOf(current.clusterConfig); |
| 551 | const autoscalingChanged = |
| 552 | desiredAutoscaling !== undefined && |
| 553 | autoscalingKey(current.clusterConfig) !== |
| 554 | autoscalingKey(news.clusterConfig); |
| 555 | const disableAutoscaling = |
| 556 | desiredAutoscaling === undefined && |
| 557 | news.serveNodes !== undefined && |
| 558 | observedAutoscaling !== undefined; |
| 559 | const serveNodesChanged = |
| 560 | desiredAutoscaling === undefined && |
| 561 | news.serveNodes !== undefined && |
| 562 | (current.serveNodes ?? DEFAULT_SERVE_NODES) !== news.serveNodes; |
| 563 | |
| 564 | if (autoscalingChanged) { |
| 565 | const patched = |
| 566 | yield* bigtable.partialUpdateClusterProjectsInstancesClusters({ |
| 567 | name, |
| 568 | updateMask: "cluster_config.cluster_autoscaling_config", |
| 569 | body: { |
| 570 | name, |
| 571 | clusterConfig: desiredAutoscaling, |
| 572 | }, |
| 573 | }); |
| 574 | yield* waitForOperation(patched); |
| 575 | current = yield* waitUntilReady(name); |
| 576 | } else if (disableAutoscaling || serveNodesChanged) { |
| 577 | const serveNodes = |
| 578 | news.serveNodes ?? current.serveNodes ?? DEFAULT_SERVE_NODES; |
| 579 | if (disableAutoscaling) { |
| 580 | const patched = |
| 581 | yield* bigtable.partialUpdateClusterProjectsInstancesClusters({ |
| 582 | name, |
| 583 | updateMask: |
| 584 | "serve_nodes,cluster_config.cluster_autoscaling_config", |
| 585 | body: { |
| 586 | name, |
| 587 | serveNodes, |
| 588 | clusterConfig: {}, |
| 589 | }, |
| 590 | }); |
| 591 | yield* waitForOperation(patched); |
| 592 | } else { |
| 593 | const updated = yield* bigtable.updateProjectsInstancesClusters({ |
| 594 | name, |
| 595 | body: { |
| 596 | name, |
| 597 | location: current.location ?? clusterLocation(env.project, zone), |
| 598 | serveNodes, |
| 599 | defaultStorageType: current.defaultStorageType ?? storage, |
| 600 | }, |
| 601 | }); |
| 602 | yield* waitForOperation(updated); |
| 603 | } |
| 604 | current = yield* waitUntilReady(name); |
| 605 | } |
| 606 | |
| 607 | return toAttrs(current, env.project); |
| 608 | }), |
| 609 | |
| 610 | delete: Effect.fn(function* ({ output }) { |
| 611 | const result = yield* bigtable |
| 612 | .deleteProjectsInstancesClusters({ name: output.name }) |
| 613 | .pipe( |
| 614 | Effect.retry({ |
| 615 | while: (error) => error._tag === "Conflict", |
| 616 | times: 8, |
| 617 | schedule: Schedule.spaced("5 seconds"), |
| 618 | }), |
| 619 | Effect.as("deleted" as const), |
| 620 | Effect.catchTag("NotFound", () => Effect.succeed("deleted" as const)), |
| 621 | // The instance's last cluster goes away with the instance. |
| 622 | Effect.catchTag("LastClusterDeletion", () => |
| 623 | Effect.succeed("kept" as const), |
| 624 | ), |
| 625 | ); |
| 626 | if (result === "deleted") { |
| 627 | yield* waitUntilGone(output.name); |
| 628 | } |
| 629 | }), |
| 630 | }); |