@highstate/backend 0.9.15 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/dist/chunk-NAAIDR4U.js +8499 -0
  2. package/dist/chunk-NAAIDR4U.js.map +1 -0
  3. package/dist/chunk-OU5OQBLB.js +74 -0
  4. package/dist/chunk-OU5OQBLB.js.map +1 -0
  5. package/dist/chunk-Y7DXREVO.js +1745 -0
  6. package/dist/chunk-Y7DXREVO.js.map +1 -0
  7. package/dist/highstate.manifest.json +4 -4
  8. package/dist/index.js +7227 -2501
  9. package/dist/index.js.map +1 -1
  10. package/dist/library/package-resolution-worker.js +7 -5
  11. package/dist/library/package-resolution-worker.js.map +1 -1
  12. package/dist/library/worker/main.js +76 -185
  13. package/dist/library/worker/main.js.map +1 -1
  14. package/dist/magic-string.es-5ABAC4JN.js +1292 -0
  15. package/dist/magic-string.es-5ABAC4JN.js.map +1 -0
  16. package/dist/shared/index.js +3 -98
  17. package/dist/shared/index.js.map +1 -1
  18. package/package.json +31 -10
  19. package/src/artifact/abstractions.ts +46 -0
  20. package/src/artifact/encryption.ts +109 -0
  21. package/src/artifact/factory.ts +36 -0
  22. package/src/artifact/index.ts +3 -0
  23. package/src/artifact/local.ts +138 -0
  24. package/src/business/__traces__/secret/update-instance-secrets/create-and-delete-secrets-simultaneously.md +356 -0
  25. package/src/business/__traces__/secret/update-instance-secrets/create-new-secrets-for-instance.md +274 -0
  26. package/src/business/__traces__/secret/update-instance-secrets/delete-existing-secrets.md +223 -0
  27. package/src/business/__traces__/secret/update-instance-secrets/no-op-when-no-changes.md +147 -0
  28. package/src/business/__traces__/secret/update-instance-secrets/update-existing-secrets.md +280 -0
  29. package/src/business/__traces__/worker/update-unit-registrations/add-new-unit-registration-when-other-exists.md +360 -0
  30. package/src/business/__traces__/worker/update-unit-registrations/add-new-unit-registration.md +215 -0
  31. package/src/business/__traces__/worker/update-unit-registrations/create-multiple-workers-with-different-identities.md +427 -0
  32. package/src/business/__traces__/worker/update-unit-registrations/handle-nonexistent-registration-id-gracefully.md +217 -0
  33. package/src/business/__traces__/worker/update-unit-registrations/no-op-when-no-changes.md +132 -0
  34. package/src/business/__traces__/worker/update-unit-registrations/recreate-worker-when-image-changes.md +454 -0
  35. package/src/business/__traces__/worker/update-unit-registrations/recreate-worker-when-image-version-changes.md +426 -0
  36. package/src/business/__traces__/worker/update-unit-registrations/recreate-worker-with-same-identity-reuses-service-account.md +372 -0
  37. package/src/business/__traces__/worker/update-unit-registrations/remove-one-of-multiple-unit-registrations.md +383 -0
  38. package/src/business/__traces__/worker/update-unit-registrations/remove-unit-registration.md +245 -0
  39. package/src/business/__traces__/worker/update-unit-registrations/update-existing-unit-registration-when-params-change.md +174 -0
  40. package/src/business/__traces__/worker/update-unit-registrations/update-params-and-image-simultaneously.md +432 -0
  41. package/src/business/__traces__/worker/update-unit-registrations/worker-with-multiple-registrations-not-deleted-when-one-removed.md +220 -0
  42. package/src/business/api-key.ts +65 -0
  43. package/src/business/artifact.ts +289 -0
  44. package/src/business/backend-unlock.ts +10 -0
  45. package/src/business/index.ts +10 -0
  46. package/src/business/instance-lock.ts +125 -0
  47. package/src/business/instance-state.ts +434 -0
  48. package/src/business/operation.ts +251 -0
  49. package/src/business/project-unlock.ts +260 -0
  50. package/src/business/project.ts +299 -0
  51. package/src/business/secret.test.ts +178 -0
  52. package/src/business/secret.ts +281 -0
  53. package/src/business/worker.test.ts +614 -0
  54. package/src/business/worker.ts +398 -0
  55. package/src/common/clock.ts +18 -0
  56. package/src/common/index.ts +5 -1
  57. package/src/common/performance.ts +44 -0
  58. package/src/common/random.ts +68 -0
  59. package/src/common/test/index.ts +2 -0
  60. package/src/common/test/render.ts +98 -0
  61. package/src/common/test/tracer.ts +359 -0
  62. package/src/common/tree.ts +33 -0
  63. package/src/common/utils.ts +40 -1
  64. package/src/config.ts +19 -11
  65. package/src/hotstate/abstractions.ts +48 -0
  66. package/src/hotstate/factory.ts +17 -0
  67. package/src/{secret → hotstate}/index.ts +1 -0
  68. package/src/hotstate/manager.ts +192 -0
  69. package/src/hotstate/memory.ts +100 -0
  70. package/src/hotstate/validation.ts +100 -0
  71. package/src/index.ts +2 -1
  72. package/src/library/abstractions.ts +24 -28
  73. package/src/library/factory.ts +2 -2
  74. package/src/library/local.ts +91 -111
  75. package/src/library/worker/evaluator.ts +36 -73
  76. package/src/library/worker/loader.lite.ts +54 -0
  77. package/src/library/worker/main.ts +15 -66
  78. package/src/library/worker/protocol.ts +6 -33
  79. package/src/lock/abstractions.ts +6 -0
  80. package/src/lock/factory.ts +15 -0
  81. package/src/lock/index.ts +4 -0
  82. package/src/lock/manager.ts +97 -0
  83. package/src/lock/memory.ts +19 -0
  84. package/src/lock/test.ts +108 -0
  85. package/src/orchestrator/manager.ts +118 -90
  86. package/src/orchestrator/operation-workset.ts +181 -93
  87. package/src/orchestrator/operation.ts +1021 -283
  88. package/src/project/abstractions.ts +27 -38
  89. package/src/project/evaluation.ts +248 -0
  90. package/src/project/factory.ts +1 -1
  91. package/src/project/index.ts +1 -2
  92. package/src/project/local.ts +107 -103
  93. package/src/pubsub/abstractions.ts +13 -0
  94. package/src/pubsub/factory.ts +19 -0
  95. package/src/{workspace → pubsub}/index.ts +1 -0
  96. package/src/pubsub/local.ts +36 -0
  97. package/src/pubsub/manager.ts +108 -0
  98. package/src/pubsub/validation.ts +33 -0
  99. package/src/runner/abstractions.ts +155 -68
  100. package/src/runner/artifact-env.ts +160 -0
  101. package/src/runner/factory.ts +20 -5
  102. package/src/runner/force-abort.ts +117 -0
  103. package/src/runner/local.ts +292 -372
  104. package/src/{common → runner}/pulumi.ts +89 -37
  105. package/src/services.ts +251 -40
  106. package/src/shared/index.ts +3 -11
  107. package/src/shared/models/backend/index.ts +3 -0
  108. package/src/shared/{library.ts → models/backend/library.ts} +4 -4
  109. package/src/shared/models/backend/project.ts +82 -0
  110. package/src/shared/models/backend/unlock-method.ts +20 -0
  111. package/src/shared/models/base.ts +68 -0
  112. package/src/shared/models/errors.ts +5 -0
  113. package/src/shared/models/index.ts +4 -0
  114. package/src/shared/models/project/api-key.ts +65 -0
  115. package/src/shared/models/project/artifact.ts +83 -0
  116. package/src/shared/models/project/index.ts +13 -0
  117. package/src/shared/models/project/lock.ts +91 -0
  118. package/src/shared/models/project/model.ts +14 -0
  119. package/src/shared/{operation.ts → models/project/operation.ts} +29 -8
  120. package/src/shared/models/project/page.ts +57 -0
  121. package/src/shared/models/project/secret.ts +98 -0
  122. package/src/shared/models/project/service-account.ts +22 -0
  123. package/src/shared/models/project/state.ts +449 -0
  124. package/src/shared/models/project/terminal.ts +98 -0
  125. package/src/shared/models/project/trigger.ts +56 -0
  126. package/src/shared/models/project/unlock-method.ts +38 -0
  127. package/src/shared/models/project/worker.ts +107 -0
  128. package/src/shared/resolvers/graph-resolver.ts +61 -18
  129. package/src/shared/resolvers/index.ts +5 -0
  130. package/src/shared/resolvers/input-hash.ts +53 -15
  131. package/src/shared/resolvers/input.ts +47 -13
  132. package/src/shared/resolvers/registry.ts +3 -2
  133. package/src/shared/resolvers/state.ts +2 -2
  134. package/src/shared/resolvers/validation.ts +82 -25
  135. package/src/shared/utils/args.ts +25 -0
  136. package/src/shared/{async-batcher.ts → utils/async-batcher.ts} +13 -1
  137. package/src/shared/utils/hash.ts +6 -0
  138. package/src/shared/utils/index.ts +4 -0
  139. package/src/shared/utils/promise-tracker.ts +23 -0
  140. package/src/state/abstractions.ts +199 -131
  141. package/src/state/encryption.ts +98 -0
  142. package/src/state/factory.ts +3 -5
  143. package/src/state/index.ts +4 -0
  144. package/src/state/keyring.ts +22 -0
  145. package/src/state/local/backend.ts +106 -0
  146. package/src/state/local/collection.ts +361 -0
  147. package/src/state/local/index.ts +2 -0
  148. package/src/state/manager.ts +875 -18
  149. package/src/state/memory/backend.ts +70 -0
  150. package/src/state/memory/collection.ts +270 -0
  151. package/src/state/memory/index.ts +2 -0
  152. package/src/state/repository/index.ts +2 -0
  153. package/src/state/repository/repository.index.ts +193 -0
  154. package/src/state/repository/repository.ts +507 -0
  155. package/src/state/test.ts +457 -0
  156. package/src/terminal/{shared.ts → abstractions.ts} +3 -3
  157. package/src/terminal/docker.ts +18 -14
  158. package/src/terminal/factory.ts +3 -3
  159. package/src/terminal/index.ts +1 -1
  160. package/src/terminal/manager.ts +131 -79
  161. package/src/terminal/run.sh.ts +21 -11
  162. package/src/unlock/abstractions.ts +49 -0
  163. package/src/unlock/index.ts +2 -0
  164. package/src/unlock/memory.ts +32 -0
  165. package/src/worker/abstractions.ts +42 -0
  166. package/src/worker/docker.ts +83 -0
  167. package/src/worker/factory.ts +20 -0
  168. package/src/worker/index.ts +3 -0
  169. package/src/worker/manager.ts +167 -0
  170. package/dist/chunk-KTGKNSKM.js +0 -979
  171. package/dist/chunk-KTGKNSKM.js.map +0 -1
  172. package/dist/chunk-WXDYCRTT.js +0 -234
  173. package/dist/chunk-WXDYCRTT.js.map +0 -1
  174. package/src/library/worker/loader.ts +0 -114
  175. package/src/preferences/shared.ts +0 -1
  176. package/src/project/lock.ts +0 -39
  177. package/src/project/manager.ts +0 -433
  178. package/src/secret/abstractions.ts +0 -59
  179. package/src/secret/factory.ts +0 -22
  180. package/src/secret/local.ts +0 -152
  181. package/src/shared/project.ts +0 -62
  182. package/src/shared/state.ts +0 -247
  183. package/src/shared/terminal.ts +0 -14
  184. package/src/state/local.ts +0 -612
  185. package/src/workspace/abstractions.ts +0 -41
  186. package/src/workspace/factory.ts +0 -14
  187. package/src/workspace/local.ts +0 -54
@@ -1,114 +1,204 @@
1
1
  import type { LibraryBackend } from "../library"
2
- import type { LogEntry, StateBackend, StateManager } from "../state"
3
- import type { ProjectBackend, ProjectLock } from "../project"
4
- import type { SecretBackend } from "../secret"
5
- import type { RunnerBackend } from "../runner"
6
- import type { InstanceLogsEvents, OperationEvents } from "./manager"
7
- import type { EventEmitter } from "node:events"
2
+ import type { StateManager } from "../state"
3
+ import type { ProjectBackend } from "../project"
4
+ import type {
5
+ RunnerArtifact,
6
+ RunnerBackend,
7
+ TypedUnitStateUpdate,
8
+ UnitStateUpdate,
9
+ } from "../runner"
8
10
  import type { Logger } from "pino"
9
- import { isUnitModel, parseInstanceId, type InstanceModel } from "@highstate/contract"
10
- import { mapValues } from "remeda"
11
+ import type {
12
+ InstanceLockService,
13
+ InstanceStateService,
14
+ OperationService,
15
+ SecretService,
16
+ WorkerService,
17
+ } from "../business"
18
+ import type { PubSubManager } from "../pubsub"
19
+ import { randomBytes } from "node:crypto"
20
+ import { v7 as uuidv7 } from "uuid"
21
+ import {
22
+ isUnitModel,
23
+ parseInstanceId,
24
+ type ComponentModel,
25
+ type InstanceModel,
26
+ type UnitSecretModel,
27
+ } from "@highstate/contract"
28
+ import { mapValues, unique } from "remeda"
29
+ import { ArtifactService } from "../artifact"
11
30
  import {
12
31
  type InstanceState,
13
- type InstanceStateUpdate,
14
- type ProjectOperation,
15
- type InstanceTriggerInvocation,
16
- createInstanceState,
17
- createAsyncBatcher,
32
+ type UnitPage,
33
+ type Operation,
34
+ type Page,
35
+ type Trigger,
36
+ type UnitTrigger,
37
+ type UnitTerminal,
38
+ type Terminal,
39
+ type TerminalSpec,
40
+ type TriggerInvocation,
41
+ PromiseTracker,
42
+ type InstanceStatePatch,
43
+ type PageBlock,
44
+ type Project,
18
45
  } from "../shared"
19
46
  import {
47
+ AbortError,
20
48
  errorToString,
21
- isAbortError,
22
49
  isAbortErrorLike,
23
- tryWrapAbortErrorLike,
50
+ PerformanceLogger,
51
+ stringToValue,
24
52
  valueToString,
53
+ waitForAbort,
25
54
  } from "../common"
26
55
  import { OperationWorkset, type OperationPhase } from "./operation-workset"
27
56
 
28
57
  export class RuntimeOperation {
29
58
  private readonly abortController = new AbortController()
59
+ private readonly forceAbortController = new AbortController()
60
+
61
+ private readonly instanceAbortControllers = new Map<string, AbortController>()
62
+ private readonly instanceForceAbortControllers = new Map<string, AbortController>()
63
+
30
64
  private readonly instancePromiseMap = new Map<string, Promise<void>>()
31
- private workset!: OperationWorkset
65
+ private readonly promiseTracker = new PromiseTracker()
32
66
 
33
- private currentPhase!: OperationPhase
67
+ private workset!: OperationWorkset
34
68
 
35
69
  constructor(
36
- private readonly operation: ProjectOperation,
70
+ private readonly project: Project,
71
+ private readonly operation: Operation,
37
72
  private readonly runnerBackend: RunnerBackend,
38
- private readonly stateBackend: StateBackend,
39
73
  private readonly libraryBackend: LibraryBackend,
40
74
  private readonly projectBackend: ProjectBackend,
41
- private readonly secretBackend: SecretBackend,
42
- private readonly projectLock: ProjectLock,
75
+ private readonly artifactService: ArtifactService,
43
76
  private readonly stateManager: StateManager,
44
- private readonly operationEE: EventEmitter<OperationEvents>,
45
- private readonly instanceLogsEE: EventEmitter<InstanceLogsEvents>,
77
+ private readonly instanceLockService: InstanceLockService,
78
+ private readonly operationService: OperationService,
79
+ private readonly secretService: SecretService,
80
+ private readonly instanceStateService: InstanceStateService,
81
+ private readonly pubsubManager: PubSubManager,
82
+ private readonly workerService: WorkerService,
46
83
  private readonly logger: Logger,
47
84
  ) {}
48
85
 
49
86
  async operateSafe(): Promise<void> {
50
87
  try {
51
88
  await this.operate()
89
+
90
+ // ensure that all promises are resolved
91
+ await this.promiseTracker.waitForAll()
52
92
  } catch (error) {
53
- if (isAbortError(error)) {
93
+ if (isAbortErrorLike(error)) {
54
94
  this.logger.info("the operation was cancelled")
55
95
  this.operation.status = "cancelled"
96
+ this.operation.error = null
56
97
 
57
- await this.updateOperation()
98
+ await this.operationService.updateOperation(this.project.id, this.operation)
58
99
  return
59
100
  }
60
101
 
61
- this.logger.error({ msg: "an error occurred while running the operation", error })
102
+ this.logger.error({ error }, "an error occurred while running the operation")
62
103
 
63
104
  this.operation.status = "failed"
64
105
  this.operation.error = errorToString(error)
65
106
 
66
- await this.updateOperation()
107
+ await this.operationService.updateOperation(this.project.id, this.operation)
67
108
  } finally {
68
- await Promise.all([
69
- this.persistStates.flush(),
70
- this.persistLogs.flush(),
71
- this.persistSecrets.flush(),
72
- ])
73
-
74
- this.logger.debug("operation finished, all entries persisted")
109
+ try {
110
+ // ensure that all promises are resolved even if the operation failed
111
+ await this.promiseTracker.waitForAll()
112
+ } catch (error) {
113
+ this.logger.error(
114
+ { error },
115
+ "one of the tracked promises failed after the operation failed",
116
+ )
117
+ }
75
118
  }
76
119
  }
77
120
 
78
121
  private async operate(): Promise<void> {
79
122
  this.logger.info("starting operation")
80
- let lockInstanceIds: string[]
81
-
82
- // keep recalculating the workset until we can acquire the locks and actually start the operation
83
- while (true) {
84
- this.workset = await OperationWorkset.load(
85
- this.operation,
86
- this.projectBackend,
87
- this.libraryBackend,
88
- this.stateBackend,
89
- this.stateManager,
90
- this.logger,
91
- this.abortController.signal,
92
- )
93
123
 
94
- lockInstanceIds = this.workset.getLockInstanceIds()
124
+ // create the workset for the operation
125
+ this.workset = await OperationWorkset.load(
126
+ this.project,
127
+ this.operation,
128
+ this.projectBackend,
129
+ this.libraryBackend,
130
+ this.stateManager,
131
+ this.instanceLockService,
132
+ this.instanceStateService,
133
+ this.logger,
134
+ this.abortController.signal,
135
+ )
95
136
 
96
- if (this.projectLock.canImmediatelyAcquireLocks(lockInstanceIds)) {
97
- break
98
- }
137
+ // start the process to lock more instances when they become available
138
+ const lockAbortSignal = new AbortController()
139
+ this.promiseTracker.track(this.tryLockMore(lockAbortSignal.signal))
140
+
141
+ // try to lock as many instances as possible
142
+ await this.workset.tryLock()
99
143
 
100
- this.logger.info("waiting for locks to be available")
144
+ if (this.workset.instanceIdsToLockIds.size === 0) {
145
+ this.logger.debug("successfully locked all instances for the operation before starting")
146
+ lockAbortSignal.abort()
147
+ }
101
148
 
102
- await this.projectLock.lockInstances(lockInstanceIds, () => Promise.resolve())
149
+ // setup abort controllers for the instances
150
+ for (const instanceId of this.workset.getAllAffectedInstanceIds()) {
151
+ this.setupInstanceAbortControllers(instanceId)
103
152
  }
104
153
 
105
154
  try {
106
- // actually acquire the locks and start the operation
107
- await this.projectLock.lockInstances(lockInstanceIds, () => this.processOperation())
155
+ // run the operation
156
+ await this.processOperation()
108
157
  } finally {
158
+ lockAbortSignal.abort()
159
+
109
160
  if (this.operation.type === "preview") {
110
161
  // stream initial states for preview operations
111
- this.workset.emitAffectedInitialStates()
162
+ await this.workset.restoreAffectedInitialStates()
163
+ }
164
+ }
165
+ }
166
+
167
+ private setupInstanceAbortControllers(instanceId: string): void {
168
+ const abortController = new AbortController()
169
+ this.abortController.signal.addEventListener("abort", () => abortController.abort())
170
+ this.instanceAbortControllers.set(instanceId, abortController)
171
+
172
+ const forceAbortController = new AbortController()
173
+ this.forceAbortController.signal.addEventListener("abort", () => forceAbortController.abort())
174
+ this.instanceForceAbortControllers.set(instanceId, forceAbortController)
175
+ }
176
+
177
+ private async tryLockMore(signal: AbortSignal): Promise<void> {
178
+ const eventStream = this.pubsubManager.subscribe(["instance-lock", this.project.id], signal)
179
+
180
+ for await (const event of eventStream) {
181
+ if (event.type !== "unlocked") {
182
+ continue
183
+ }
184
+
185
+ const instanceIdsToLock = event.instanceIds.filter(
186
+ //
187
+ instanceId => this.workset.instanceIdsToLockIds.has(instanceId),
188
+ )
189
+
190
+ if (instanceIdsToLock.length === 0) {
191
+ try {
192
+ await this.workset.tryLock(instanceIdsToLock)
193
+ } catch (error) {
194
+ this.logger.error({ error }, "failed to lock more instances during operation")
195
+ }
196
+ }
197
+
198
+ if (this.workset.instanceIdsToLockIds.size === 0) {
199
+ // no more instances to lock, stop listening for events
200
+ this.logger.debug("successfully locked all instances for the operation")
201
+ break
112
202
  }
113
203
  }
114
204
  }
@@ -118,22 +208,22 @@ export class RuntimeOperation {
118
208
  this.operation.instanceIdsToDestroy = this.workset.operation.instanceIdsToDestroy
119
209
 
120
210
  this.logger.info(
121
- {
122
- instanceIdsToUpdate: this.operation.instanceIdsToUpdate,
123
- instanceIdsToDestroy: this.operation.instanceIdsToDestroy,
124
- },
125
- "operation started",
211
+ "operation started with %s instances to update and %s to destroy",
212
+ this.operation.instanceIdsToUpdate.length,
213
+ this.operation.instanceIdsToDestroy.length,
126
214
  )
127
215
 
128
216
  const phases = this.getOperationPhases()
217
+ const errors: string[] = []
218
+ let hasAbortError = false
129
219
 
130
220
  for (const phase of phases) {
131
- this.currentPhase = phase
221
+ this.workset.currentPhase = phase
132
222
 
133
223
  const promises: Promise<void>[] = []
134
- for (const instanceId of this.workset.getAffectedInstanceIds(phase)) {
135
- const parentId = this.workset.getParentId(instanceId, phase)
136
- if (parentId && this.workset.isAffected(parentId, phase)) {
224
+ for (const instanceId of this.workset.getAffectedInstanceIds()) {
225
+ const parentId = this.workset.getParentId(instanceId)
226
+ if (parentId && this.workset.isAffected(parentId)) {
137
227
  // do not call the operation for child instances of affected composites,
138
228
  // they will be called by their parent instance
139
229
  continue
@@ -144,23 +234,94 @@ export class RuntimeOperation {
144
234
 
145
235
  this.logger.info(`all operations for phase "%s" started`, phase)
146
236
  this.operation.status = "running"
147
- await this.updateOperation()
237
+ await this.operationService.updateOperation(this.project.id, this.operation)
148
238
 
149
- await Promise.all(promises)
239
+ const phaseResults = await Promise.allSettled(promises)
240
+ for (const result of phaseResults) {
241
+ if (result.status !== "rejected") {
242
+ continue
243
+ }
244
+
245
+ if (isAbortErrorLike(result.reason)) {
246
+ hasAbortError = true
247
+ } else {
248
+ errors.push(errorToString(result.reason))
249
+ }
250
+ }
150
251
 
151
252
  this.logger.info(`all operations for phase "%s" completed`, phase)
152
253
  }
153
254
 
154
- this.operation.status = "completed"
155
- this.operation.error = null
156
- await this.updateOperation()
255
+ if (errors.length > 0) {
256
+ this.operation.status = "failed"
257
+ // TODO: map errors to instances inside operation
258
+ this.operation.error = errors.join("\n\n")
259
+ } else if (hasAbortError) {
260
+ this.operation.status = "cancelled"
261
+ this.operation.error = null
262
+ } else {
263
+ this.operation.status = "completed"
264
+ this.operation.error = null
265
+ }
266
+
267
+ await this.operationService.updateOperation(this.project.id, this.operation)
157
268
 
158
269
  this.logger.info("operation completed")
159
270
  }
160
271
 
161
- cancel() {
162
- this.logger.info("cancelling operation")
163
- this.abortController.abort()
272
+ cancelInstance(instanceId: string): void {
273
+ const abortController = this.instanceAbortControllers.get(instanceId)
274
+ if (!abortController) {
275
+ throw new Error(`No abort controller found for instance "${instanceId}"`)
276
+ }
277
+
278
+ if (!abortController.signal.aborted) {
279
+ this.logger.info(`cancelling operation for instance "${instanceId}"`)
280
+ abortController.abort()
281
+
282
+ // just the UX feature to indicate that we are trying to cancel the operation
283
+ this.promiseTracker.track(
284
+ this.workset.patchState({
285
+ id: instanceId,
286
+ operationStatus: {
287
+ status: "cancelling",
288
+ },
289
+ }),
290
+ )
291
+
292
+ return
293
+ }
294
+
295
+ // then try to force cancel the operation
296
+ const forceAbortController = this.instanceForceAbortControllers.get(instanceId)
297
+ if (!forceAbortController) {
298
+ throw new Error(`No force abort controller found for instance "${instanceId}"`)
299
+ }
300
+
301
+ if (!forceAbortController.signal.aborted) {
302
+ this.logger.info(`force cancelling operation for instance "${instanceId}"`)
303
+ forceAbortController.abort()
304
+ return
305
+ }
306
+
307
+ this.logger.warn(`operation for instance "${instanceId}" is already force cancelled`)
308
+ }
309
+
310
+ cancel(): void {
311
+ if (!this.abortController.signal.aborted) {
312
+ this.logger.info("cancelling the operation")
313
+ this.abortController.abort()
314
+ return
315
+ }
316
+
317
+ // then try to force cancel the operation
318
+ if (!this.forceAbortController.signal.aborted) {
319
+ this.logger.info("force cancelling the operation")
320
+ this.forceAbortController.abort()
321
+ return
322
+ }
323
+
324
+ this.logger.warn("the operation is already cancelled or force cancelled")
164
325
  }
165
326
 
166
327
  private getInstancePromiseForOperation(instanceId: string): Promise<void> {
@@ -195,7 +356,7 @@ export class RuntimeOperation {
195
356
  }
196
357
 
197
358
  private async getUnitPromise(instanceId: string): Promise<void> {
198
- switch (this.currentPhase) {
359
+ switch (this.workset.currentPhase) {
199
360
  case "update": {
200
361
  return this.updateUnit(instanceId)
201
362
  }
@@ -208,22 +369,13 @@ export class RuntimeOperation {
208
369
  }
209
370
  }
210
371
 
211
- private async getCompositePromise(instanceId: string): Promise<void> {
212
- const logger = this.logger.child({ instanceId })
213
-
214
- return this.getInstancePromise(instanceId, async () => {
215
- const state = this.workset.getState(instanceId) ?? createInstanceState(instanceId)
216
- const instance = this.workset.getInstanceOrUndefined(instanceId)
372
+ private getCompositePromise(instanceId: string): Promise<void> {
373
+ return this.getInstancePromise(instanceId, async logger => {
374
+ const instance = this.workset.getInstance(instanceId)
217
375
 
218
- this.updateInstanceState({
219
- ...state,
220
- parentId: instance?.parentId,
221
- latestOperationId: this.operation.id,
222
- status: "pending",
223
- error: null,
224
- })
376
+ await this.setInitialOperationStatus(instance)
225
377
 
226
- const children = this.workset.getAffectedCompositeChildren(instanceId, this.currentPhase)
378
+ const children = this.workset.getAffectedCompositeChildren(instanceId)
227
379
  const childPromises: Promise<void>[] = []
228
380
 
229
381
  if (children.length) {
@@ -233,15 +385,12 @@ export class RuntimeOperation {
233
385
  }
234
386
 
235
387
  for (const child of children) {
236
- if (
237
- !this.operation.options.forceUpdateChildren &&
238
- !this.workset.isAffected(child.id, this.currentPhase)
239
- ) {
388
+ if (!this.operation.options.forceUpdateChildren && !this.workset.isAffected(child.id)) {
240
389
  // skip children that are not affected by the operation
241
390
  continue
242
391
  }
243
392
 
244
- logger.debug(`waiting for child: "%s"`, child.id)
393
+ logger.debug(`waiting for child "%s"`, child.id)
245
394
  childPromises.push(this.getInstancePromiseForOperation(child.id))
246
395
  }
247
396
 
@@ -249,105 +398,130 @@ export class RuntimeOperation {
249
398
  await Promise.all(childPromises)
250
399
 
251
400
  if (children.length > 0) {
252
- logger.info("all children completed")
401
+ logger.debug("all children completed")
402
+ }
403
+
404
+ if (this.operation.type === "destroy") {
405
+ await this.clearOperationState(instanceId)
406
+ return
253
407
  }
254
408
 
255
- this.updateInstanceState({
409
+ const { inputHash, dependencyOutputHash } =
410
+ await this.workset.getUpToDateInputHashOutput(instance)
411
+
412
+ await this.workset.patchState({
256
413
  id: instanceId,
257
- status: this.operation.type === "destroy" ? "not_created" : "created",
258
- inputHash: instance ? await this.workset.getUpToDateInputHash(instance) : undefined,
414
+ operationStatus: {
415
+ status: "created",
416
+ inputHash,
417
+ dependencyOutputHash,
418
+ },
259
419
  })
260
420
  } catch (error) {
261
421
  if (isAbortErrorLike(error)) {
262
- this.workset.restoreInitialStatus(instanceId)
422
+ await this.workset.restoreInitialState(instanceId)
263
423
  return
264
424
  }
265
425
 
266
- this.updateInstanceState({
426
+ await this.workset.patchState({
267
427
  id: instanceId,
268
- status: "error",
269
- error: errorToString(error),
428
+ operationStatus: {
429
+ status: "error",
430
+ message: errorToString(error),
431
+ },
270
432
  })
271
433
  }
272
434
  })
273
435
  }
274
436
 
275
437
  private updateUnit(instanceId: string): Promise<void> {
276
- return this.getInstancePromise(instanceId, async logger => {
438
+ return this.getInstancePromise(instanceId, async (logger, signal, forceSignal) => {
277
439
  const instance = this.workset.getInstance(instanceId)
440
+ const component = this.workset.library.components[instance.type]
441
+ const perfLogger = new PerformanceLogger(logger)
442
+ perfLogger.log("starting update promise for instance")
278
443
 
279
- this.updateInstanceState({
280
- id: instance.id,
281
- parentId: instance.parentId,
282
- latestOperationId: this.operation.id,
283
- status: "pending",
284
- error: null,
285
- currentResourceCount: 0,
286
- })
444
+ await this.setInitialOperationStatus(instance)
445
+ perfLogger.log("initial operation status set")
287
446
 
288
- let dependencyIds: string[] = []
289
- try {
290
- dependencyIds = await this.updateUnitDependencies(instance, logger)
291
- } catch (error) {
292
- // restore the initial status of the instance if one of the dependencies failed
293
- this.workset.restoreInitialStatus(instance.id)
294
- throw error
447
+ await Promise.race([
448
+ this.updateUnitDependencies(instance.id, logger),
449
+
450
+ // to immediately abort the operation if requested
451
+ waitForAbort(signal),
452
+ ])
453
+
454
+ if (!signal.aborted) {
455
+ perfLogger.log("dependencies updated")
295
456
  }
296
457
 
458
+ signal.throwIfAborted()
459
+
297
460
  logger.info("updating unit")
298
461
 
299
- const secrets = await this.secretBackend.get(this.operation.projectId, instance.id)
300
- this.abortController.signal.throwIfAborted()
462
+ await this.workset.patchState({
463
+ id: instance.id,
464
+ operationStatus: {
465
+ status: "updating",
466
+ currentResourceCount: 0,
467
+ totalResourceCount: 0,
468
+ },
469
+ })
470
+
471
+ perfLogger.log("set 'updating' operation status")
472
+ signal.throwIfAborted()
473
+
474
+ const secrets = await this.secretService.getInstanceSecretValues(this.project, instance.id)
301
475
 
302
- logger.debug("secrets loaded", { count: Object.keys(secrets).length })
476
+ perfLogger.log("secrets loaded")
477
+ signal.throwIfAborted()
478
+
479
+ const config = this.prepareUnitConfig(instance, component)
480
+ perfLogger.log("unit config prepared")
481
+
482
+ // extract artifact hashes from dependencies based on instance inputs
483
+ const artifactHashes = this.collectArtifactIdsForInstance(instance)
484
+ const artifacts = await this.stateManager
485
+ .getArtifactHashIndexRepository(this.project.id)
486
+ .getManyItems(artifactHashes)
487
+
488
+ logger.debug({ count: artifactHashes.length }, "artifact hashes collected from dependencies")
489
+ perfLogger.log("artifact hashes collected")
303
490
 
304
491
  await this.runnerBackend[this.operation.type === "preview" ? "preview" : "update"]({
305
- projectId: this.operation.projectId,
492
+ projectId: this.project.id,
493
+ libraryId: this.project.libraryId,
306
494
  instanceType: instance.type,
307
495
  instanceName: instance.name,
308
- config: this.prepareUnitConfig(instance),
496
+ config,
309
497
  refresh: this.operation.options.refresh,
310
498
  secrets: mapValues(secrets, value => valueToString(value)),
311
- signal: this.abortController.signal,
312
- })
313
-
314
- logger.debug("unit update requested")
315
-
316
- const stream = this.runnerBackend.watch({
317
- projectId: this.operation.projectId,
318
- instanceType: instance.type,
319
- instanceName: instance.name,
320
- finalStatuses: ["created", "error"],
499
+ artifacts,
500
+ signal,
501
+ forceSignal,
321
502
  })
322
503
 
323
- await this.watchStateStream(stream)
504
+ perfLogger.log("unit update requested")
324
505
 
325
- const inputHash = await this.workset.getUpToDateInputHash(instance)
326
-
327
- this.updateInstanceState({
328
- id: instance.id,
329
- inputHash,
330
- dependencyIds,
331
- })
332
-
333
- logger.debug("input hash after update", { inputHash })
506
+ await this.watchStateStream(instance.type, instance.name, logger)
507
+ perfLogger.log("unit update completed")
334
508
  logger.info("unit updated")
335
509
  })
336
510
  }
337
511
 
338
- private async updateUnitDependencies(instance: InstanceModel, logger: Logger): Promise<string[]> {
512
+ private async updateUnitDependencies(instanceId: string, logger: Logger): Promise<void> {
339
513
  try {
340
- const dependencies = this.getInstanceDependencies(instance)
514
+ const dependencies = this.getInstanceDependencyIds(instanceId)
341
515
  const dependencyPromises: Promise<void>[] = []
342
516
 
343
- for (const dependency of dependencies) {
344
- if (!this.operation.instanceIdsToUpdate.includes(dependency.id)) {
517
+ for (const dependencyId of dependencies) {
518
+ if (!this.operation.instanceIdsToUpdate.includes(dependencyId)) {
345
519
  // skip dependencies that are not affected by the operation
346
520
  continue
347
521
  }
348
522
 
349
- logger.debug(`waiting for dependency: ${dependency.id}`)
350
- dependencyPromises.push(this.getInstancePromiseForOperation(dependency.id))
523
+ logger.debug(`waiting for dependency "${dependencyId}"`)
524
+ dependencyPromises.push(this.getInstancePromiseForOperation(dependencyId))
351
525
  }
352
526
 
353
527
  await Promise.all(dependencyPromises)
@@ -355,71 +529,90 @@ export class RuntimeOperation {
355
529
  if (dependencies.length > 0) {
356
530
  logger.info("all dependencies completed")
357
531
  }
358
-
359
- return dependencies.map(dependency => dependency.id)
360
- } catch (error) {
361
- // restore the initial status of the instance if one of the dependencies failed
362
- this.workset.restoreInitialStatus(instance.id)
363
- throw error
532
+ } catch {
533
+ // abort the instance if any dependency fails
534
+ throw new AbortError()
364
535
  }
365
536
  }
366
537
 
367
- private async processBeforeDestroyTriggers(state: InstanceState, logger: Logger): Promise<void> {
538
+ private async processBeforeDestroyTriggers(
539
+ state: InstanceState,
540
+ logger: Logger,
541
+ signal: AbortSignal,
542
+ forceSignal: AbortSignal,
543
+ ): Promise<void> {
368
544
  if (!this.operation.options.invokeDestroyTriggers) {
369
545
  logger.debug("destroy triggers are disabled for the operation")
370
546
  return
371
547
  }
372
548
 
373
549
  const instance = this.workset.getInstance(state.id)
550
+ const component = this.workset.library.components[instance.type]
374
551
 
375
- const triggers = state.triggers.filter(trigger => trigger.spec.type === "before-destroy")
376
- if (triggers.length === 0) {
552
+ // fetch triggers from state backend by their IDs
553
+ const triggerIds = Object.values(state.extra?.triggerIds ?? {})
554
+
555
+ const allTriggers = await this.stateManager
556
+ .getTriggerRepository(this.project.id)
557
+ .getManyItems(triggerIds)
558
+
559
+ const beforeDestroyTriggers = allTriggers.filter(
560
+ trigger => trigger.spec.type === "before-destroy",
561
+ )
562
+
563
+ if (beforeDestroyTriggers.length === 0) {
377
564
  return
378
565
  }
379
566
 
380
- const invokedTriggers = triggers.map(trigger => ({ name: trigger.name }))
567
+ // get trigger names from state mapping by finding the key for each trigger ID
568
+ const invokedTriggers = beforeDestroyTriggers.map(trigger => {
569
+ const triggerName = Object.keys(state?.extra?.triggerIds ?? {}).find(
570
+ name => state?.extra?.triggerIds?.[name] === trigger.id,
571
+ )
572
+
573
+ return { name: triggerName || "unknown" }
574
+ })
575
+
576
+ await this.workset.patchState({
577
+ id: instance.id,
578
+ operationStatus: {
579
+ status: "processing-triggers",
580
+ },
581
+ })
381
582
 
382
583
  logger.info("updating unit to process before-destroy triggers...")
383
584
 
384
- const secrets = await this.secretBackend.get(
385
- this.operation.projectId,
386
- instance.id,
387
- this.abortController.signal,
388
- )
585
+ const secrets = await this.secretService.getInstanceSecretValues(this.project, instance.id)
389
586
 
390
587
  await this.runnerBackend.update({
391
- projectId: this.operation.projectId,
588
+ projectId: this.project.id,
589
+ libraryId: this.project.libraryId,
392
590
  instanceType: instance.type,
393
591
  instanceName: instance.name,
394
- config: this.prepareUnitConfig(instance, invokedTriggers),
592
+ config: this.prepareUnitConfig(instance, component, invokedTriggers),
395
593
  refresh: this.operation.options.refresh,
396
594
  secrets: mapValues(secrets, value => valueToString(value)),
397
- signal: this.abortController.signal,
595
+ signal,
596
+ forceSignal,
398
597
  })
399
598
 
400
599
  logger.debug("unit update requested")
401
600
 
402
- const stream = this.runnerBackend.watch({
403
- projectId: this.operation.projectId,
404
- instanceType: instance.type,
405
- instanceName: instance.name,
406
- finalStatuses: ["created", "error"],
407
- })
408
-
409
- await this.watchStateStream(stream)
410
-
601
+ await this.watchStateStream(instance.type, instance.name, logger)
411
602
  logger.debug("before-destroy triggers processed")
412
603
  }
413
604
 
414
605
  private async destroyUnit(instanceId: string): Promise<void> {
415
- return this.getInstancePromise(instanceId, async logger => {
416
- this.updateInstanceState({
606
+ return this.getInstancePromise(instanceId, async (logger, signal, forceSignal) => {
607
+ await this.workset.patchState({
417
608
  id: instanceId,
418
- latestOperationId: this.operation.id,
419
- status: "pending",
420
- error: null,
609
+ operationStatus: {
610
+ status: "pending",
611
+ },
421
612
  })
422
613
 
614
+ signal.throwIfAborted()
615
+
423
616
  const state = this.workset.getState(instanceId)
424
617
  if (!state) {
425
618
  logger.warn("state not found for unit, but destroy was requested")
@@ -442,48 +635,65 @@ export class RuntimeOperation {
442
635
  }
443
636
 
444
637
  await Promise.all(dependentPromises)
445
- this.abortController.signal.throwIfAborted()
638
+ signal.throwIfAborted()
446
639
 
447
- await this.processBeforeDestroyTriggers(state, logger)
640
+ await this.processBeforeDestroyTriggers(state, logger, signal, forceSignal)
641
+ signal.throwIfAborted()
448
642
 
449
643
  logger.info("destroying unit...")
450
644
 
645
+ await this.workset.patchState({
646
+ id: instanceId,
647
+ operationStatus: {
648
+ status: "destroying",
649
+ },
650
+ })
651
+
451
652
  const [type, name] = parseInstanceId(instanceId)
452
653
 
453
654
  await this.runnerBackend.destroy({
454
- projectId: this.operation.projectId,
655
+ projectId: this.project.id,
656
+ libraryId: this.project.libraryId,
455
657
  instanceType: type,
456
658
  instanceName: name,
457
659
  refresh: this.operation.options.refresh,
458
- signal: this.abortController.signal,
660
+ signal,
661
+ forceSignal,
459
662
  deleteUnreachable: this.operation.options.deleteUnreachableResources,
460
663
  forceDeleteState: this.operation.options.forceDeleteState,
461
664
  })
462
665
 
463
- this.logger.debug("destroy request sent")
666
+ logger.debug("destroy request sent")
464
667
 
465
- const stream = this.runnerBackend.watch({
466
- projectId: this.operation.projectId,
467
- instanceType: type,
468
- instanceName: name,
469
- finalStatuses: ["not_created", "error"],
470
- })
668
+ await this.watchStateStream(type, name, logger)
669
+
670
+ // clean up artifacts after successful destruction
671
+ try {
672
+ const artifactIds = unique(Object.values(state.extra?.exportedArtifactIds ?? {}).flat())
673
+
674
+ await this.artifactService.removeUsages(
675
+ //
676
+ this.project.id,
677
+ artifactIds,
678
+ [{ type: "instance", instanceId: state.id }],
679
+ )
680
+ } catch (error) {
681
+ logger.warn({ error }, "failed to cleanup artifacts for destroyed instance")
682
+ }
471
683
 
472
- await this.watchStateStream(stream)
473
- this.logger.info("unit destroyed")
684
+ logger.info("unit destroyed")
474
685
  })
475
686
  }
476
687
 
477
688
  private async refreshUnit(instanceId: string) {
478
- const logger = this.logger.child({ instanceId })
479
-
480
- return this.getInstancePromise(instanceId, async () => {
481
- this.updateInstanceState({
689
+ return this.getInstancePromise(instanceId, async (logger, signal, forceSignal) => {
690
+ await this.workset.patchState({
482
691
  id: instanceId,
483
- latestOperationId: this.operation.id,
484
- status: "refreshing",
485
- currentResourceCount: 0,
486
- totalResourceCount: 0,
692
+ operationStatus: {
693
+ status: "refreshing",
694
+ currentResourceCount: 0,
695
+ totalResourceCount: 0,
696
+ },
487
697
  })
488
698
 
489
699
  logger.info("refreshing unit...")
@@ -491,62 +701,84 @@ export class RuntimeOperation {
491
701
  const [type, name] = parseInstanceId(instanceId)
492
702
 
493
703
  await this.runnerBackend.refresh({
494
- projectId: this.operation.projectId,
704
+ projectId: this.project.id,
705
+ libraryId: this.project.libraryId,
495
706
  instanceType: type,
496
707
  instanceName: name,
497
- signal: this.abortController.signal,
708
+ signal,
709
+ forceSignal,
498
710
  })
499
711
 
500
712
  logger.debug("unit refresh requested")
501
713
 
502
- const stream = this.runnerBackend.watch({
503
- projectId: this.operation.projectId,
504
- instanceType: type,
505
- instanceName: name,
506
- finalStatuses: ["created", "error"],
507
- })
508
-
509
- await this.watchStateStream(stream)
714
+ await this.watchStateStream(type, name, logger)
510
715
  logger.info("unit refreshed")
511
716
  })
512
717
  }
513
718
 
514
- private async watchStateStream(stream: AsyncIterable<InstanceStateUpdate>) {
515
- let statePatch: InstanceStateUpdate | undefined
516
- for await (statePatch of stream) {
517
- if (statePatch.status === "not_created" && this.operation.type === "recreate") {
518
- // do not stream "not_created" status for recreate operation to improve UX
519
- continue
719
+ private async watchStateStream(
720
+ instanceType: string,
721
+ instanceName: string,
722
+ logger: Logger,
723
+ ): Promise<void> {
724
+ const stream = this.runnerBackend.watch({
725
+ projectId: this.project.id,
726
+ libraryId: this.project.libraryId,
727
+ instanceType,
728
+ instanceName,
729
+ })
730
+
731
+ let update: UnitStateUpdate | undefined
732
+
733
+ for await (update of stream) {
734
+ try {
735
+ await this.handleUnitStateUpdate(update)
736
+ } catch (error) {
737
+ logger.error({ error }, "failed to handle unit state update")
520
738
  }
521
739
 
522
- this.updateInstanceState(statePatch)
523
- }
740
+ if (update.type === "error") {
741
+ if (isAbortErrorLike(update.message)) {
742
+ // abort the unit if the returned error contains some abort-like pattern
743
+ // generally, this might not be safe, but for now, we assume that
744
+ throw new AbortError()
745
+ }
524
746
 
525
- if (!statePatch) {
526
- throw new Error("The stream ended without emitting any state.")
527
- }
747
+ // rethrow the error to stop the execution of dependent units
748
+ throw new Error(`An error occurred while processing the unit "${update.unitId}"`, {
749
+ cause: update.message,
750
+ })
751
+ }
528
752
 
529
- if (statePatch.status === "error") {
530
- throw tryWrapAbortErrorLike(
531
- new Error(`The operation on unit "${statePatch.id}" failed: ${statePatch.error}`),
532
- )
753
+ if (update.type === "completion") {
754
+ return
755
+ }
533
756
  }
757
+
758
+ throw new Error(
759
+ "The unit state stream was closed without a completion update or it was not handled properly.",
760
+ )
534
761
  }
535
762
 
536
763
  private prepareUnitConfig(
537
764
  instance: InstanceModel,
538
- invokedTriggers: InstanceTriggerInvocation[] = [],
765
+ component: ComponentModel,
766
+ invokedTriggers: TriggerInvocation[] = [],
539
767
  ): Record<string, string> {
540
768
  const config: Record<string, string> = {}
541
769
 
542
- for (const [key, value] of Object.entries(instance.args ?? {})) {
543
- config[key] = valueToString(value)
770
+ for (const key of Object.keys(component.args)) {
771
+ // explicitly set empty values to remove old values in the config
772
+ const value = instance.args?.[key]
773
+ config[key] = value ? valueToString(value) : ""
544
774
  }
545
775
 
546
776
  const instanceInputs = this.workset.resolvedInstanceInputs.get(instance.id) ?? {}
547
777
 
548
- for (const [key, value] of Object.entries(instanceInputs)) {
549
- config[`input.${key}`] = JSON.stringify(value.map(input => input.input))
778
+ for (const key of Object.keys(component.inputs)) {
779
+ const inputs = instanceInputs[key] ?? []
780
+
781
+ config[`input.${key}`] = JSON.stringify(inputs.map(input => input.input))
550
782
  }
551
783
 
552
784
  config["$invokedTriggers"] = JSON.stringify(invokedTriggers)
@@ -554,49 +786,181 @@ export class RuntimeOperation {
554
786
  return config
555
787
  }
556
788
 
557
- private async updateOperation(): Promise<void> {
558
- this.operationEE.emit(this.operation.projectId, this.operation)
559
- await this.stateBackend.putOperation(this.operation)
789
+ private async handleUnitStateUpdate(update: UnitStateUpdate): Promise<void> {
790
+ switch (update.type) {
791
+ case "message":
792
+ this.handleUnitMessage(update)
793
+ return
794
+ case "progress":
795
+ await this.handleUnitProgress(update)
796
+ return
797
+ case "error":
798
+ await this.handleUnitError(update)
799
+ return
800
+ case "completion":
801
+ await this.handleUnitCompletion(update)
802
+ return
803
+ }
804
+ }
805
+
806
+ private handleUnitMessage(update: TypedUnitStateUpdate<"message">): void {
807
+ // collect all instances in the hierarchy that should receive this log
808
+ const instanceIdsInHierarchy: string[] = []
809
+ let instance: InstanceModel | null = this.workset.getInstance(update.unitId)
810
+
811
+ for (;;) {
812
+ instanceIdsInHierarchy.push(instance.id)
813
+
814
+ if (!instance.parentId) {
815
+ break
816
+ }
817
+
818
+ instance = this.workset.getInstance(instance.parentId)
819
+ }
820
+
821
+ // persist the log for all instances in the hierarchy
822
+ // TODO: batch
823
+ this.promiseTracker.track(
824
+ this.operationService.appendLogs(this.project.id, this.operation.id, instanceIdsInHierarchy, [
825
+ update.message,
826
+ ]),
827
+ )
828
+ return
560
829
  }
561
830
 
562
- private updateInstanceState(patch: InstanceStateUpdate): void {
563
- if (!patch.id) {
564
- throw new Error("The ID of the instance state is required.")
831
+ private async handleUnitProgress(update: TypedUnitStateUpdate<"progress">): Promise<void> {
832
+ const patch: InstanceStatePatch = { id: update.unitId }
833
+ patch.operationStatus = {}
834
+
835
+ if (update.currentResourceCount !== undefined) {
836
+ patch.operationStatus.currentResourceCount = update.currentResourceCount
565
837
  }
566
838
 
567
- if (patch.logLine) {
568
- // recursively persist logs for instance and all parent instances
839
+ if (update.totalResourceCount !== undefined) {
840
+ patch.operationStatus.totalResourceCount = update.totalResourceCount
841
+ }
569
842
 
570
- let instance: InstanceModel | null = this.workset.getInstance(patch.id)
843
+ await this.workset.patchState(patch)
844
+ }
571
845
 
572
- for (;;) {
573
- this.persistLogs.call([instance.id, patch.logLine])
574
- this.instanceLogsEE.emit(`${this.operation.id}/${instance.id}`, patch.logLine)
846
+ private async handleUnitError(update: TypedUnitStateUpdate<"error">): Promise<void> {
847
+ if (isAbortErrorLike(update.message)) {
848
+ return
849
+ }
575
850
 
576
- if (!instance.parentId) {
577
- break
578
- }
851
+ const patch: InstanceStatePatch = {
852
+ id: update.unitId,
853
+ operationStatus: {
854
+ status: "error",
855
+ message: update.message,
856
+ },
857
+ }
579
858
 
580
- instance = this.workset.getInstance(instance.parentId)
581
- }
859
+ await this.workset.patchState(patch)
860
+ }
861
+
862
+ private async handleUnitCompletion(update: TypedUnitStateUpdate<"completion">): Promise<void> {
863
+ if (this.workset.currentPhase === "destroy") {
864
+ // if the destroy operation was completed, clear the state from all operation-related fields
865
+ await this.clearOperationState(update.unitId)
582
866
  return
583
867
  }
584
868
 
585
- const state = this.workset.updateState(patch, this.currentPhase)
869
+ const state = this.workset.getState(update.unitId)
870
+ if (!state) {
871
+ throw new Error(
872
+ `Cannot handle completion for unit "${update.unitId}" because its state is not found.`,
873
+ )
874
+ }
586
875
 
587
- // do not persist anyting for preview operations
588
- if (this.operation.type !== "preview") {
589
- this.persistStates.call(state)
876
+ const instance = this.workset.getInstance(update.unitId)
590
877
 
591
- if (patch.secrets) {
592
- this.persistSecrets.call([patch.id, patch.secrets])
593
- }
878
+ // patch the state with output hash first to recalculate the input hash
879
+ if (update.outputHash) {
880
+ await this.workset.patchState({
881
+ id: update.unitId,
882
+ operationStatus: { outputHash: update.outputHash },
883
+ })
594
884
  }
885
+
886
+ const { inputHash, dependencyOutputHash } =
887
+ await this.workset.getUpToDateInputHashOutput(instance)
888
+
889
+ const patch: InstanceStatePatch = {
890
+ id: update.unitId,
891
+ operationStatus: {
892
+ status: "created",
893
+ message:
894
+ update.message ?? "The operation on this instance has been completed successfully.",
895
+ outputHash: update.outputHash ?? undefined,
896
+ inputHash,
897
+ dependencyOutputHash,
898
+ },
899
+ extra: {
900
+ statusFields: update.statusFields,
901
+
902
+ terminalIds: update.terminals
903
+ ? this.processTerminals(update.unitId, update.terminals, state.extra?.terminalIds)
904
+ : null,
905
+
906
+ pageIds: update.pages
907
+ ? this.processPages(update.unitId, update.pages, state.extra?.pageIds)
908
+ : null,
909
+
910
+ triggerIds: update.triggers
911
+ ? this.processTriggers(update.unitId, update.triggers, state.extra?.triggerIds)
912
+ : null,
913
+
914
+ workerRegistrationIds: update.workers
915
+ ? await this.workerService.updateUnitRegistrations(
916
+ this.project.id,
917
+ update.unitId,
918
+ state.extra?.workerRegistrationIds ?? {},
919
+ update.workers,
920
+ )
921
+ : null,
922
+
923
+ exportedArtifactIds: update.exportedArtifacts
924
+ ? this.processArtifacts(
925
+ update.unitId,
926
+ update.exportedArtifacts,
927
+ state.extra?.exportedArtifactIds,
928
+ )
929
+ : null,
930
+
931
+ exportedSecretIds: update.exportedSecrets
932
+ ? this.processExportedSecrets(update.unitId, update.exportedSecrets)
933
+ : null,
934
+ },
935
+ }
936
+
937
+ if (update.secrets) {
938
+ const instance = this.workset.getInstance(update.unitId)
939
+
940
+ this.promiseTracker.track(
941
+ this.updateInstanceSecrets(
942
+ instance,
943
+ mapValues(update.secrets, value => stringToValue(value)),
944
+ ),
945
+ )
946
+ }
947
+
948
+ await this.workset.patchState(patch)
949
+ }
950
+
951
+ private async clearOperationState(instanceId: string): Promise<void> {
952
+ const patch: InstanceStatePatch = {
953
+ id: instanceId,
954
+ operationStatus: null,
955
+ extra: null,
956
+ }
957
+
958
+ await this.workset.patchState(patch)
595
959
  }
596
960
 
597
961
  private getInstancePromise(
598
962
  instanceId: string,
599
- fn: (logger: Logger) => Promise<void>,
963
+ fn: (logger: Logger, signal: AbortSignal, forceSignal: AbortSignal) => Promise<void>,
600
964
  ): Promise<void> {
601
965
  let instancePromise = this.instancePromiseMap.get(instanceId)
602
966
  if (instancePromise) {
@@ -604,56 +968,430 @@ export class RuntimeOperation {
604
968
  }
605
969
 
606
970
  const logger = this.logger.child({ instanceId }, { msgPrefix: `[${instanceId}] ` })
971
+ const abortController = this.instanceAbortControllers.get(instanceId)
972
+ const forceAbortController = this.instanceForceAbortControllers.get(instanceId)
973
+
974
+ if (!abortController || !forceAbortController) {
975
+ throw new Error(`Abort controllers for instance "${instanceId}" are not initialized`)
976
+ }
977
+
978
+ instancePromise = this.workset
979
+ .ensureInstanceLocked(instanceId, abortController.signal)
980
+ .then(() => fn(logger, abortController.signal, forceAbortController.signal))
981
+ .catch(error => {
982
+ if (isAbortErrorLike(error)) {
983
+ // if the operation was aborted, restore the initial state of the instance
984
+ this.promiseTracker.track(this.workset.restoreInitialState(instanceId))
985
+
986
+ throw error
987
+ }
988
+
989
+ if (this.operation.status !== "failing") {
990
+ // report the failing status of the operation
991
+ this.operation.status = "failing"
992
+ this.promiseTracker.track(
993
+ this.operationService.updateOperation(this.project.id, this.operation),
994
+ )
995
+ }
996
+
997
+ // rethrow the error
998
+ throw error
999
+ })
1000
+ .finally(() => {
1001
+ this.instancePromiseMap.delete(instanceId)
1002
+
1003
+ // TODO: ideally we should defer unlocking until all direct dependents are completed,
1004
+ // to ensure that they are received expected inputs from this instance
1005
+ this.promiseTracker.track(
1006
+ this.instanceLockService.unlockInstancesUnconditionally(this.project.id, [instanceId]),
1007
+ )
1008
+ })
607
1009
 
608
- instancePromise = fn(logger).finally(() => this.instancePromiseMap.delete(instanceId))
609
1010
  this.instancePromiseMap.set(instanceId, instancePromise)
610
1011
 
611
1012
  return instancePromise
612
1013
  }
613
1014
 
614
- private getInstanceDependencies(instance: InstanceModel): InstanceModel[] {
615
- const dependencies: InstanceModel[] = []
1015
+ private async setInitialOperationStatus(instance: InstanceModel): Promise<void> {
1016
+ const state = this.workset.getState(instance.id)
1017
+
1018
+ await this.workset.patchState({
1019
+ id: instance.id,
1020
+ parentId: instance.parentId,
1021
+ operationStatus: {
1022
+ status: "pending",
1023
+ message: "",
1024
+ dependencyIds: this.getInstanceDependencyIds(instance.id),
1025
+ latestOperationId: this.operation.id,
1026
+ inputHashNonce: state?.operationStatus?.inputHashNonce ?? randomBytes(4).readInt32LE(),
1027
+ args: instance.args,
1028
+ },
1029
+ })
1030
+ }
1031
+
1032
+ private getInstanceDependencyIds(instanceId: string): string[] {
1033
+ const dependencies = new Set<string>()
1034
+ const instanceInputs = this.workset.resolvedInstanceInputs.get(instanceId) ?? {}
1035
+
1036
+ for (const inputs of Object.values(instanceInputs)) {
1037
+ for (const input of inputs) {
1038
+ dependencies.add(input.input.instanceId)
1039
+ }
1040
+ }
1041
+
1042
+ return Array.from(dependencies)
1043
+ }
1044
+
1045
+ /**
1046
+ * Collects artifact IDs from dependencies based on the direct connections
1047
+ * from instance inputs to dependency outputs.
1048
+ */
1049
+ private collectArtifactIdsForInstance(instance: InstanceModel): string[] {
1050
+ const artifactIds = new Set<string>()
616
1051
  const instanceInputs = this.workset.resolvedInstanceInputs.get(instance.id) ?? {}
617
1052
 
618
1053
  for (const inputs of Object.values(instanceInputs)) {
619
1054
  for (const input of inputs) {
620
- const dependency = this.workset.getInstance(input.input.instanceId)
1055
+ const dependencyState = this.workset.getState(input.input.instanceId)
1056
+ if (!dependencyState?.extra?.exportedArtifactIds) {
1057
+ continue
1058
+ }
621
1059
 
622
- dependencies.push(dependency)
1060
+ const outputKey = input.input.output
1061
+ const outputArtifacts = dependencyState.extra.exportedArtifactIds[outputKey]
1062
+ if (!outputArtifacts) {
1063
+ continue
1064
+ }
1065
+
1066
+ for (const hash of outputArtifacts) {
1067
+ artifactIds.add(hash)
1068
+ }
623
1069
  }
624
1070
  }
625
1071
 
626
- return dependencies
1072
+ return Array.from(artifactIds)
627
1073
  }
628
1074
 
629
- private persistStates = createAsyncBatcher(async (states: InstanceState[]) => {
630
- this.logger.debug({ msg: "persisting states", count: states.length })
1075
+ /**
1076
+ * Collects secret IDs from dependencies based on the direct connections
1077
+ * from instance inputs to dependency outputs.
1078
+ */
1079
+ private collectSecretIdsForInstance(instance: InstanceModel): Set<string> {
1080
+ const secretIds = new Set<string>()
1081
+ const instanceInputs = this.workset.resolvedInstanceInputs.get(instance.id) ?? {}
1082
+
1083
+ for (const inputs of Object.values(instanceInputs)) {
1084
+ for (const input of inputs) {
1085
+ const dependencyState = this.workset.getState(input.input.instanceId)
1086
+ if (!dependencyState?.extra?.exportedSecretIds) {
1087
+ continue
1088
+ }
1089
+
1090
+ const outputKey = input.input.output
1091
+ const outputSecrets = dependencyState.extra.exportedSecretIds[outputKey]
1092
+ if (!outputSecrets) {
1093
+ continue
1094
+ }
1095
+
1096
+ for (const secretId of outputSecrets) {
1097
+ secretIds.add(secretId)
1098
+ }
1099
+ }
1100
+ }
631
1101
 
632
- await this.stateBackend.putAffectedInstanceStates(
633
- this.operation.projectId,
634
- this.operation.id,
635
- states,
1102
+ return secretIds
1103
+ }
1104
+
1105
+ /**
1106
+ * Updates the secrets for a specific instance.
1107
+ * Processes terminals from instance state update.
1108
+ *
1109
+ * Converts unit terminals to instance terminals and handles cleanup.
1110
+ */
1111
+ private processTerminals(
1112
+ instanceId: string,
1113
+ unitTerminals: UnitTerminal[],
1114
+ currentTerminalIds: Record<string, string> | undefined,
1115
+ ): Record<string, string> {
1116
+ const terminals: Terminal[] = unitTerminals.map(unitTerminal => ({
1117
+ id: currentTerminalIds?.[unitTerminal.name] ?? uuidv7(),
1118
+ instanceId,
1119
+ meta: unitTerminal.meta ?? {},
1120
+ }))
1121
+
1122
+ const terminalSpecEntries: [string, TerminalSpec][] = unitTerminals
1123
+ //
1124
+ .map((unitTerminal, index) => [terminals[index].id, unitTerminal.spec])
1125
+
1126
+ // create mapping from local name to UUID
1127
+ const newTerminalIds: Record<string, string> = {}
1128
+ for (let i = 0; i < unitTerminals.length; i++) {
1129
+ const unitTerminal = unitTerminals[i]
1130
+ const instanceTerminal = terminals[i]
1131
+ newTerminalIds[unitTerminal.name] = instanceTerminal.id
1132
+ }
1133
+
1134
+ // find terminals that need to be deleted (old terminals not in new list)
1135
+ const terminalsToDelete = Object.values(currentTerminalIds ?? {}).filter(
1136
+ id => !Object.values(newTerminalIds).includes(id),
1137
+ )
1138
+
1139
+ if (terminals.length > 0) {
1140
+ this.promiseTracker.track(this.putTerminals(terminals, terminalSpecEntries))
1141
+ }
1142
+
1143
+ if (terminalsToDelete.length > 0) {
1144
+ this.promiseTracker.track(this.deleteTerminals(terminalsToDelete))
1145
+ }
1146
+
1147
+ return newTerminalIds
1148
+ }
1149
+
1150
+ private async putTerminals(
1151
+ terminals: Terminal[],
1152
+ terminalSpecEntries: [string, TerminalSpec][],
1153
+ ): Promise<void> {
1154
+ if (this.operation.type === "preview") {
1155
+ return
1156
+ }
1157
+
1158
+ await using batch = this.stateManager.batch()
1159
+
1160
+ await Promise.all([
1161
+ this.stateManager.getTerminalRepository(this.project.id).putManyItems(terminals, batch),
1162
+
1163
+ this.stateManager
1164
+ .getTerminalSpecRepository(this.project.id)
1165
+ .putMany(terminalSpecEntries, batch),
1166
+ ])
1167
+
1168
+ await batch.write()
1169
+ }
1170
+
1171
+ private async deleteTerminals(terminalIds: string[]): Promise<void> {
1172
+ if (this.operation.type === "preview") {
1173
+ return
1174
+ }
1175
+
1176
+ await using batch = this.stateManager.batch()
1177
+
1178
+ await Promise.all([
1179
+ this.stateManager.getTerminalRepository(this.project.id).deleteMany(terminalIds, batch),
1180
+ this.stateManager.getTerminalSpecRepository(this.project.id).deleteMany(terminalIds, batch),
1181
+ ])
1182
+
1183
+ await batch.write()
1184
+ }
1185
+
1186
+ /**
1187
+ * Processes pages from instance state update.
1188
+ *
1189
+ * Converts unit pages to instance pages and handles cleanup.
1190
+ */
1191
+ private processPages(
1192
+ instanceId: string,
1193
+ unitPages: UnitPage[],
1194
+ currentPageIds: Record<string, string> | undefined,
1195
+ ): Record<string, string> {
1196
+ const pages: Page[] = unitPages.map(unitPage => ({
1197
+ id: currentPageIds?.[unitPage.name] ?? uuidv7(),
1198
+ instanceId,
1199
+ meta: unitPage.meta,
1200
+ }))
1201
+
1202
+ const pageContentEntries: [string, PageBlock[]][] = unitPages
1203
+ //
1204
+ .map((unitPage, index) => [pages[index].id, unitPage.content])
1205
+
1206
+ // create mapping from local name to UUID
1207
+ const newPageIds: Record<string, string> = {}
1208
+ for (let i = 0; i < unitPages.length; i++) {
1209
+ const unitPage = unitPages[i]
1210
+ const instancePage = pages[i]
1211
+ newPageIds[unitPage.name] = instancePage.id
1212
+ }
1213
+
1214
+ // find pages that need to be deleted (old pages not in new list)
1215
+ const pagesToDelete = Object.values(currentPageIds ?? {}).filter(
1216
+ id => !Object.values(newPageIds).includes(id),
1217
+ )
1218
+
1219
+ if (pages.length > 0) {
1220
+ this.promiseTracker.track(this.putPages(pages, pageContentEntries))
1221
+ }
1222
+
1223
+ if (pagesToDelete.length > 0) {
1224
+ this.promiseTracker.track(this.deletePages(pagesToDelete))
1225
+ }
1226
+
1227
+ return newPageIds
1228
+ }
1229
+
1230
+ private async putPages(
1231
+ pages: Page[],
1232
+ pageContentEntries: [string, PageBlock[]][],
1233
+ ): Promise<void> {
1234
+ if (this.operation.type === "preview") {
1235
+ return
1236
+ }
1237
+
1238
+ const batch = this.stateManager.batch()
1239
+
1240
+ await Promise.all([
1241
+ this.stateManager.getPageRepository(this.project.id).putManyItems(pages, batch),
1242
+ this.stateManager
1243
+ .getPageContentRepository(this.project.id)
1244
+ .putMany(pageContentEntries, batch),
1245
+ ])
1246
+
1247
+ await batch.write()
1248
+ }
1249
+
1250
+ private async deletePages(pageIds: string[]): Promise<void> {
1251
+ if (this.operation.type === "preview") {
1252
+ return
1253
+ }
1254
+
1255
+ const batch = this.stateManager.batch()
1256
+
1257
+ await Promise.all([
1258
+ this.stateManager.getPageRepository(this.project.id).deleteMany(pageIds, batch),
1259
+ this.stateManager.getPageContentRepository(this.project.id).deleteMany(pageIds, batch),
1260
+ ])
1261
+
1262
+ await batch.write()
1263
+ }
1264
+
1265
+ /**
1266
+ * Processes triggers from instance state update.
1267
+ *
1268
+ * Converts unit triggers to instance triggers and handles cleanup.
1269
+ */
1270
+ private processTriggers(
1271
+ instanceId: string,
1272
+ unitTriggers: UnitTrigger[],
1273
+ currentTriggerIds: Record<string, string> | undefined,
1274
+ ): Record<string, string> {
1275
+ const triggers: Trigger[] = unitTriggers.map(unitTrigger => ({
1276
+ id: currentTriggerIds?.[unitTrigger.name] ?? uuidv7(),
1277
+ instanceId,
1278
+ meta: unitTrigger.meta ?? {},
1279
+ spec: unitTrigger.spec,
1280
+ }))
1281
+
1282
+ // create mapping from local name to UUID
1283
+ const newTriggerIds: Record<string, string> = {}
1284
+ for (let i = 0; i < unitTriggers.length; i++) {
1285
+ const unitTrigger = unitTriggers[i]
1286
+ newTriggerIds[unitTrigger.name] = triggers[i].id
1287
+ }
1288
+
1289
+ // find triggers that need to be deleted (old triggers not in new list)
1290
+ const triggersToDelete = Object.values(currentTriggerIds ?? {}).filter(
1291
+ id => !Object.values(newTriggerIds).includes(id),
636
1292
  )
637
- })
638
1293
 
639
- private persistLogs = createAsyncBatcher(async (entries: LogEntry[]) => {
640
- this.logger.trace({ msg: "persisting logs", count: entries.length })
1294
+ if (triggers.length > 0) {
1295
+ this.promiseTracker.track(this.putTriggers(triggers))
1296
+ }
1297
+
1298
+ if (triggersToDelete.length > 0) {
1299
+ this.promiseTracker.track(this.deleteTriggers(triggersToDelete))
1300
+ }
1301
+
1302
+ return newTriggerIds
1303
+ }
1304
+
1305
+ private async putTriggers(triggers: Trigger[]): Promise<void> {
1306
+ if (this.operation.type === "preview") {
1307
+ return
1308
+ }
1309
+
1310
+ await this.stateManager.getTriggerRepository(this.project.id).putManyItems(triggers)
1311
+ }
641
1312
 
642
- await this.stateBackend.appendInstanceLogs(this.operation.id, entries)
643
- })
1313
+ private async deleteTriggers(triggerIds: string[]): Promise<void> {
1314
+ if (this.operation.type === "preview") {
1315
+ return
1316
+ }
644
1317
 
645
- private persistSecrets = createAsyncBatcher(
646
- async (entries: [string, Record<string, string>][]) => {
647
- this.logger.debug({ msg: "persisting secrets", count: entries.length })
1318
+ await this.stateManager.getTriggerRepository(this.project.id).deleteMany(triggerIds)
1319
+ }
648
1320
 
649
- // TODO: may be batched (and patched without reading)
650
- for (const [instanceId, secrets] of entries) {
651
- const existingSecrets = await this.secretBackend.get(this.operation.projectId, instanceId)
1321
+ /**
1322
+ * Processes artifacts from instance state update.
1323
+ */
1324
+ private processArtifacts(
1325
+ instanceId: string,
1326
+ newOwnedArtifacts: Record<string, RunnerArtifact[]>,
1327
+ oldOwnedArtifactIds: Record<string, string[]> | undefined,
1328
+ ): Record<string, string[]> {
1329
+ if (this.operation.type !== "preview") {
1330
+ // persist the new owned artifacts if this is not a preview operation
1331
+ this.promiseTracker.track(
1332
+ this.artifactService.updateUsage(
1333
+ this.project.id,
1334
+ { type: "instance", instanceId },
1335
+ unique(Object.values(oldOwnedArtifactIds ?? {}).flat()),
1336
+ unique(
1337
+ Object.values(newOwnedArtifacts).flatMap(artifacts =>
1338
+ artifacts.map(artifact => artifact.id),
1339
+ ),
1340
+ ),
1341
+ ),
1342
+ )
1343
+ }
652
1344
 
653
- Object.assign(existingSecrets, secrets)
1345
+ return mapValues(newOwnedArtifacts, artifacts => artifacts.map(artifact => artifact.id))
1346
+ }
654
1347
 
655
- await this.secretBackend.set(this.operation.projectId, instanceId, existingSecrets)
1348
+ /**
1349
+ * Processes exported secrets from instance state update.
1350
+ *
1351
+ * Validates that all secret IDs are either present in the instance secrets or are in "exportedSecrets" of the direct dependencies
1352
+ * from connected inputs.
1353
+ *
1354
+ * Returns a mapping of output key to array of secret IDs exported via this output.
1355
+ */
1356
+ private processExportedSecrets(
1357
+ instanceId: string,
1358
+ exportedSecrets: Record<string, Omit<UnitSecretModel, "value">[]>,
1359
+ ): Record<string, string[]> {
1360
+ const instance = this.workset.getInstance(instanceId)
1361
+ const allowedSecretIds = this.collectSecretIdsForInstance(instance)
1362
+
1363
+ return mapValues(exportedSecrets, secrets => {
1364
+ const exportedSecretIds = secrets.map(secret => secret.id)
1365
+ for (const exportedSecretId of exportedSecretIds) {
1366
+ if (!allowedSecretIds.has(exportedSecretId)) {
1367
+ throw new Error(
1368
+ `The secret "${exportedSecretId}" is not allowed to be exported since it is not present in the instance secrets or in the exported secrets of connected dependencies.`,
1369
+ )
1370
+ }
656
1371
  }
657
- },
658
- )
1372
+
1373
+ return exportedSecretIds
1374
+ })
1375
+ }
1376
+
1377
+ /**
1378
+ * Updates the secrets for a specific instance.
1379
+ */
1380
+ private async updateInstanceSecrets(
1381
+ instance: InstanceModel,
1382
+ secrets: Record<string, unknown>,
1383
+ ): Promise<void> {
1384
+ if (this.operation.type === "preview") {
1385
+ // do not update secrets in preview mode
1386
+ return
1387
+ }
1388
+
1389
+ await this.secretService.updateInstanceSecrets(
1390
+ this.project,
1391
+ instance.id,
1392
+ secrets,
1393
+ [], // no secrets to delete in this context
1394
+ false, // do not invalidate state even if the secrets were updated
1395
+ )
1396
+ }
659
1397
  }