| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | |
| 6 | /** |
| 7 | * Shared waiter for Google long-running operations (`google.longrunning.Operation` |
| 8 | * and the Compute `Operation` shape). Every provider waits through this one |
| 9 | * helper so each service only states its time budget. |
| 10 | * |
| 11 | * NOT exported from `index.ts`. |
| 12 | */ |
| 13 | |
| 14 | /** The subset of a long-running operation the waiter reads. */ |
| 15 | export interface LongRunningOperation { |
| 16 | name?: string; |
| 17 | done?: boolean; |
| 18 | /** Compute operations report `status: "DONE"` instead of `done`. */ |
| 19 | status?: string; |
| 20 | error?: { |
| 21 | code?: number; |
| 22 | message?: string; |
| 23 | /** Compute operations list their errors here. */ |
| 24 | errors?: ReadonlyArray<{ code?: string; message?: string }>; |
| 25 | }; |
| 26 | } |
| 27 | |
| 28 | /** The operation finished with an error. */ |
| 29 | export class OperationFailed extends Data.TaggedError("GCP.OperationFailed")<{ |
| 30 | operation: string; |
| 31 | /** Numeric RPC code (google.rpc.Code), when reported. */ |
| 32 | code: number | undefined; |
| 33 | /** Compute error code (e.g. `RESOURCE_NOT_FOUND`), when reported. */ |
| 34 | reason: string | undefined; |
| 35 | message: string; |
| 36 | }> {} |
| 37 | |
| 38 | /** The operation did not finish within the service's time budget. */ |
| 39 | export class OperationTimedOut extends Data.TaggedError( |
| 40 | "GCP.OperationTimedOut", |
| 41 | )<{ |
| 42 | operation: string; |
| 43 | budget: string; |
| 44 | }> {} |
| 45 | |
| 46 | class OperationPending extends Data.TaggedError("GCP.OperationPending")<{ |
| 47 | operation: string; |
| 48 | }> {} |
| 49 | |
| 50 | const isDone = (operation: LongRunningOperation) => |
| 51 | operation.done === true || operation.status === "DONE"; |
| 52 | |
| 53 | const failureOf = (operation: LongRunningOperation) => { |
| 54 | const error = operation.error; |
| 55 | if (error === undefined) return undefined; |
| 56 | const first = error.errors?.[0]; |
| 57 | if (error.code === undefined && first === undefined && !error.message) { |
| 58 | return undefined; |
| 59 | } |
| 60 | return new OperationFailed({ |
| 61 | operation: operation.name ?? "", |
| 62 | code: error.code, |
| 63 | reason: first?.code, |
| 64 | message: first?.message ?? error.message ?? "operation failed", |
| 65 | }); |
| 66 | }; |
| 67 | |
| 68 | export interface WaitOptions { |
| 69 | /** |
| 70 | * How long the service may take. Use the real provisioning time for the |
| 71 | * resource (Composer ~45m, AlloyDB/Cloud SQL ~20m, GKE ~20m), not a test |
| 72 | * timeout. |
| 73 | * @default "10 minutes" |
| 74 | */ |
| 75 | budget?: Duration.Input; |
| 76 | /** Poll interval. @default "5 seconds" */ |
| 77 | interval?: Duration.Input; |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Poll `get` until the operation is done, then fail with |
| 82 | * {@link OperationFailed} if it reports an error. `get` re-reads the |
| 83 | * operation by name with the service's own operations API. |
| 84 | */ |
| 85 | export const waitForOperation = <E extends { readonly _tag: string }, R>( |
| 86 | operation: LongRunningOperation, |
| 87 | get: (name: string) => Effect.Effect<LongRunningOperation, E, R>, |
| 88 | options: WaitOptions = {}, |
| 89 | ): Effect.Effect< |
| 90 | LongRunningOperation, |
| 91 | E | OperationFailed | OperationTimedOut, |
| 92 | R |
| 93 | > => { |
| 94 | const budget = Duration.fromInputUnsafe(options.budget ?? "10 minutes"); |
| 95 | const interval = Duration.fromInputUnsafe(options.interval ?? "5 seconds"); |
| 96 | const name = operation.name ?? ""; |
| 97 | const settle = (current: LongRunningOperation) => { |
| 98 | const failed = failureOf(current); |
| 99 | return failed ? Effect.fail(failed) : Effect.succeed(current); |
| 100 | }; |
| 101 | if (isDone(operation) || name.length === 0) return settle(operation); |
| 102 | const times = Math.max( |
| 103 | 1, |
| 104 | Math.ceil(Duration.toMillis(budget) / Duration.toMillis(interval)), |
| 105 | ); |
| 106 | return get(name).pipe( |
| 107 | Effect.flatMap((current) => |
| 108 | isDone(current) |
| 109 | ? Effect.succeed(current) |
| 110 | : Effect.fail(new OperationPending({ operation: name })), |
| 111 | ), |
| 112 | Effect.retry({ |
| 113 | while: (error) => error._tag === "GCP.OperationPending", |
| 114 | schedule: Schedule.spaced(interval), |
| 115 | times, |
| 116 | }), |
| 117 | Effect.catchTag("GCP.OperationPending", () => |
| 118 | Effect.fail( |
| 119 | new OperationTimedOut({ |
| 120 | operation: name, |
| 121 | budget: Duration.format(budget), |
| 122 | }), |
| 123 | ), |
| 124 | ), |
| 125 | Effect.flatMap(settle), |
| 126 | ); |
| 127 | }; |