@highstate/backend 0.9.14 → 0.9.16

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 (144) hide show
  1. package/dist/chunk-RCB4AFGD.js +159 -0
  2. package/dist/chunk-RCB4AFGD.js.map +1 -0
  3. package/dist/chunk-WHALQHEZ.js +2017 -0
  4. package/dist/chunk-WHALQHEZ.js.map +1 -0
  5. package/dist/highstate.manifest.json +3 -3
  6. package/dist/index.js +6146 -2174
  7. package/dist/index.js.map +1 -1
  8. package/dist/library/worker/main.js +51 -159
  9. package/dist/library/worker/main.js.map +1 -1
  10. package/dist/shared/index.js +159 -43
  11. package/package.json +25 -7
  12. package/src/artifact/abstractions.ts +46 -0
  13. package/src/artifact/encryption.ts +69 -0
  14. package/src/artifact/factory.ts +36 -0
  15. package/src/artifact/index.ts +3 -0
  16. package/src/artifact/local.ts +142 -0
  17. package/src/business/api-key.ts +65 -0
  18. package/src/business/artifact.ts +288 -0
  19. package/src/business/backend-unlock.ts +10 -0
  20. package/src/business/index.ts +9 -0
  21. package/src/business/instance-lock.ts +124 -0
  22. package/src/business/instance-state.ts +292 -0
  23. package/src/business/operation.ts +251 -0
  24. package/src/business/project-unlock.ts +242 -0
  25. package/src/business/secret.ts +187 -0
  26. package/src/business/worker.ts +161 -0
  27. package/src/common/index.ts +2 -1
  28. package/src/common/performance.ts +44 -0
  29. package/src/common/tree.ts +33 -0
  30. package/src/common/utils.ts +40 -1
  31. package/src/config.ts +14 -10
  32. package/src/hotstate/abstractions.ts +48 -0
  33. package/src/hotstate/factory.ts +17 -0
  34. package/src/{secret → hotstate}/index.ts +1 -0
  35. package/src/hotstate/manager.ts +192 -0
  36. package/src/hotstate/memory.ts +100 -0
  37. package/src/hotstate/validation.ts +101 -0
  38. package/src/index.ts +2 -1
  39. package/src/library/abstractions.ts +10 -23
  40. package/src/library/factory.ts +2 -2
  41. package/src/library/local.ts +89 -102
  42. package/src/library/worker/evaluator.ts +14 -47
  43. package/src/library/worker/loader.lite.ts +41 -0
  44. package/src/library/worker/main.ts +14 -65
  45. package/src/library/worker/protocol.ts +8 -24
  46. package/src/lock/abstractions.ts +6 -0
  47. package/src/lock/factory.ts +15 -0
  48. package/src/{workspace → lock}/index.ts +1 -0
  49. package/src/lock/manager.ts +82 -0
  50. package/src/lock/memory.ts +19 -0
  51. package/src/orchestrator/manager.ts +131 -82
  52. package/src/orchestrator/operation-workset.ts +188 -77
  53. package/src/orchestrator/operation.ts +975 -284
  54. package/src/project/abstractions.ts +20 -7
  55. package/src/project/factory.ts +1 -1
  56. package/src/project/index.ts +0 -1
  57. package/src/project/local.ts +73 -17
  58. package/src/project/manager.ts +272 -131
  59. package/src/pubsub/abstractions.ts +13 -0
  60. package/src/pubsub/factory.ts +19 -0
  61. package/src/pubsub/index.ts +3 -0
  62. package/src/pubsub/local.ts +36 -0
  63. package/src/pubsub/manager.ts +100 -0
  64. package/src/pubsub/validation.ts +33 -0
  65. package/src/runner/abstractions.ts +135 -68
  66. package/src/runner/artifact-env.ts +160 -0
  67. package/src/runner/factory.ts +20 -5
  68. package/src/runner/force-abort.ts +117 -0
  69. package/src/runner/local.ts +281 -372
  70. package/src/{common → runner}/pulumi.ts +86 -37
  71. package/src/services.ts +193 -35
  72. package/src/shared/index.ts +3 -11
  73. package/src/shared/models/backend/index.ts +3 -0
  74. package/src/shared/models/backend/project.ts +63 -0
  75. package/src/shared/models/backend/unlock-method.ts +20 -0
  76. package/src/shared/models/base.ts +151 -0
  77. package/src/shared/models/errors.ts +5 -0
  78. package/src/shared/models/index.ts +4 -0
  79. package/src/shared/models/project/api-key.ts +62 -0
  80. package/src/shared/models/project/artifact.ts +113 -0
  81. package/src/shared/models/project/component.ts +45 -0
  82. package/src/shared/models/project/index.ts +14 -0
  83. package/src/shared/{project.ts → models/project/instance.ts} +12 -0
  84. package/src/shared/models/project/lock.ts +91 -0
  85. package/src/shared/{operation.ts → models/project/operation.ts} +43 -8
  86. package/src/shared/models/project/page.ts +57 -0
  87. package/src/shared/models/project/secret.ts +112 -0
  88. package/src/shared/models/project/service-account.ts +22 -0
  89. package/src/shared/models/project/state.ts +432 -0
  90. package/src/shared/models/project/terminal.ts +99 -0
  91. package/src/shared/models/project/trigger.ts +56 -0
  92. package/src/shared/models/project/unlock-method.ts +31 -0
  93. package/src/shared/models/project/worker.ts +105 -0
  94. package/src/shared/resolvers/graph-resolver.ts +74 -13
  95. package/src/shared/resolvers/index.ts +5 -0
  96. package/src/shared/resolvers/input-hash.ts +53 -15
  97. package/src/shared/resolvers/input.ts +1 -9
  98. package/src/shared/resolvers/registry.ts +7 -2
  99. package/src/shared/resolvers/state.ts +12 -0
  100. package/src/shared/resolvers/validation.ts +61 -20
  101. package/src/shared/{async-batcher.ts → utils/async-batcher.ts} +13 -1
  102. package/src/shared/utils/hash.ts +6 -0
  103. package/src/shared/utils/index.ts +3 -0
  104. package/src/shared/utils/promise-tracker.ts +23 -0
  105. package/src/state/abstractions.ts +330 -101
  106. package/src/state/encryption.ts +59 -0
  107. package/src/state/factory.ts +3 -5
  108. package/src/state/index.ts +3 -0
  109. package/src/state/keyring.ts +22 -0
  110. package/src/state/local/backend.ts +299 -0
  111. package/src/state/local/collection.ts +342 -0
  112. package/src/state/local/index.ts +2 -0
  113. package/src/state/manager.ts +804 -18
  114. package/src/state/repository/index.ts +2 -0
  115. package/src/state/repository/repository.index.ts +193 -0
  116. package/src/state/repository/repository.ts +458 -0
  117. package/src/terminal/{shared.ts → abstractions.ts} +3 -3
  118. package/src/terminal/docker.ts +18 -14
  119. package/src/terminal/factory.ts +3 -3
  120. package/src/terminal/index.ts +1 -1
  121. package/src/terminal/manager.ts +134 -80
  122. package/src/terminal/run.sh.ts +22 -10
  123. package/src/worker/abstractions.ts +42 -0
  124. package/src/worker/docker.ts +83 -0
  125. package/src/worker/factory.ts +20 -0
  126. package/src/worker/index.ts +3 -0
  127. package/src/worker/manager.ts +139 -0
  128. package/dist/chunk-C2TJAQAD.js +0 -937
  129. package/dist/chunk-C2TJAQAD.js.map +0 -1
  130. package/dist/chunk-WXDYCRTT.js +0 -234
  131. package/dist/chunk-WXDYCRTT.js.map +0 -1
  132. package/src/library/worker/loader.ts +0 -114
  133. package/src/preferences/shared.ts +0 -1
  134. package/src/project/lock.ts +0 -39
  135. package/src/secret/abstractions.ts +0 -59
  136. package/src/secret/factory.ts +0 -22
  137. package/src/secret/local.ts +0 -152
  138. package/src/shared/state.ts +0 -270
  139. package/src/shared/terminal.ts +0 -13
  140. package/src/state/local.ts +0 -612
  141. package/src/workspace/abstractions.ts +0 -41
  142. package/src/workspace/factory.ts +0 -14
  143. package/src/workspace/local.ts +0 -54
  144. /package/src/shared/{library.ts → models/backend/library.ts} +0 -0
@@ -0,0 +1,124 @@
1
+ import type { Logger } from "pino"
2
+ import type { LockManager } from "../lock"
3
+ import type { PubSubManager } from "../pubsub"
4
+ import type { StateManager } from "../state"
5
+ import { compareInstanceLockSpecs, type InstanceLockSpec, type ObjectMeta } from "../shared"
6
+
7
+ export class InstanceLockService {
8
+ constructor(
9
+ private readonly stateManager: StateManager,
10
+ private readonly lockManager: LockManager,
11
+ private readonly pubsubManager: PubSubManager,
12
+ private readonly logger: Logger,
13
+ ) {}
14
+
15
+ /**
16
+ * Tries to lock instances in the project.
17
+ * Returns true if the lock was acquired successfully, false otherwise.
18
+ *
19
+ * @param projectId The project ID to lock instances in.
20
+ * @param instanceIds The instance IDs to lock.
21
+ * @param lockMeta The metadata for the lock.
22
+ * @param spec The lock specification containing the lock type and other parameters.
23
+ * @param allowPartialLock Whether to allow partial locking of instances.
24
+ * If true, it will lock as many instances as possible even if some are already locked.
25
+ *
26
+ * @return A tuple containing a boolean indicating if the lock was acquired,
27
+ * and an array of instance IDs that were successfully locked.
28
+ * If `allowPartialLock` is false and some instances are already locked,
29
+ * it will return false and an empty array.
30
+ */
31
+ async tryLockInstances(
32
+ projectId: string,
33
+ instanceIds: string[],
34
+ lockMeta: ObjectMeta,
35
+ spec: InstanceLockSpec,
36
+ allowPartialLock = false,
37
+ ): Promise<[locked: boolean, lockedInstanceIds: string[]]> {
38
+ const lockKeys = instanceIds.map(id => ["instance-lock", projectId, id] as const)
39
+
40
+ // acquire the hard lock to ensure no other process can manage the lock entries
41
+ return await this.lockManager.acquire(lockKeys, async () => {
42
+ // check if the instances are already locked
43
+ const existingLocks = await this.stateManager
44
+ .getInstanceLockRepository(projectId)
45
+ .getManyItems(instanceIds)
46
+
47
+ // only the locks with another spec are considered as the conflicting locks
48
+ const conflictingLocks = existingLocks.filter(
49
+ lock => !compareInstanceLockSpecs(lock.spec, spec),
50
+ )
51
+
52
+ if (conflictingLocks.length > 0) {
53
+ this.logger.debug(
54
+ {
55
+ projectId,
56
+ requestedInstanceIds: instanceIds,
57
+ lockedInstanceIds: conflictingLocks.map(lock => lock.id),
58
+ },
59
+ `failed to lock %s of %s instances in project "%s"`,
60
+ conflictingLocks.length,
61
+ instanceIds.length,
62
+ projectId,
63
+ )
64
+
65
+ if (!allowPartialLock) {
66
+ // if partial locking is not allowed, return false
67
+ return [false, []]
68
+ }
69
+
70
+ // if partial locking is allowed, filter out the already locked instances and proceed with the rest
71
+ instanceIds = instanceIds.filter(id => !conflictingLocks.some(lock => lock.id === id))
72
+ }
73
+
74
+ const newLocks = instanceIds.map(id => ({ id, projectId, meta: lockMeta, spec }))
75
+
76
+ // persist the new locks
77
+ await this.stateManager.getInstanceLockRepository(projectId).putManyItems(newLocks)
78
+
79
+ this.logger.debug(
80
+ {
81
+ projectId,
82
+ instanceIds: newLocks.map(lock => lock.id),
83
+ spec,
84
+ },
85
+ `locked %s instances in project "%s"`,
86
+ newLocks.length,
87
+ projectId,
88
+ )
89
+
90
+ // publish the lock event
91
+ await this.pubsubManager.publish(["instance-lock", projectId], {
92
+ type: "locked",
93
+ locks: newLocks,
94
+ })
95
+
96
+ return [true, newLocks.map(lock => lock.id)]
97
+ })
98
+ }
99
+
100
+ /**
101
+ * Unlocks instances in the project unconditionally.
102
+ * This will remove the locks regardless of their current state.
103
+ *
104
+ * @param projectId The project ID to unlock instances in.
105
+ * @param instanceIds The instance IDs to unlock.
106
+ */
107
+ async unlockInstancesUnconditionally(projectId: string, instanceIds: string[]): Promise<void> {
108
+ this.logger.debug(
109
+ { projectId, instanceIds },
110
+ `unconditionally unlocking %s instances in project "%s"`,
111
+ instanceIds.length,
112
+ projectId,
113
+ )
114
+
115
+ // remove the locks from the state
116
+ await this.stateManager.getInstanceLockRepository(projectId).deleteMany(instanceIds)
117
+
118
+ // publish the unlock event
119
+ await this.pubsubManager.publish(["instance-lock", projectId], {
120
+ type: "unlocked",
121
+ instanceIds,
122
+ })
123
+ }
124
+ }
@@ -0,0 +1,292 @@
1
+ import type { HotStateManager } from "../hotstate"
2
+ import type { LockManager } from "../lock"
3
+ import type { PubSubManager } from "../pubsub"
4
+ import type { StateManager } from "../state"
5
+ import type { Logger } from "pino"
6
+ import type { RunnerBackend } from "../runner"
7
+ import type { ArtifactService } from "../artifact"
8
+ import { randomBytes } from "node:crypto"
9
+ import { parseInstanceId } from "@highstate/contract"
10
+ import { unique } from "remeda"
11
+ import {
12
+ applyStatePatch,
13
+ isStableOperationStatus,
14
+ isStateEmpty,
15
+ type InstanceCustomStatus,
16
+ type InstanceState,
17
+ type InstanceStatePatch,
18
+ type Operation,
19
+ } from "../shared"
20
+
21
+ export class InstanceStateService {
22
+ constructor(
23
+ private readonly stateManager: StateManager,
24
+ private readonly hotStateManager: HotStateManager,
25
+ private readonly pubsubManager: PubSubManager,
26
+ private readonly lockManager: LockManager,
27
+ private readonly runnerBackend: RunnerBackend,
28
+ private readonly artifactManager: ArtifactService,
29
+ private readonly logger: Logger,
30
+ ) {}
31
+
32
+ /**
33
+ * Gets the current states of all instances in a project.
34
+ *
35
+ * @param projectId The ID of the project for which to retrieve instance states.
36
+ */
37
+ async getInstanceStates(projectId: string): Promise<InstanceState[]> {
38
+ const entries = await this.hotStateManager.hgetall(["instance-states", projectId])
39
+
40
+ return entries.map(([, value]) => value)
41
+ }
42
+
43
+ async getInstanceState(projectId: string, instanceId: string): Promise<InstanceState | null> {
44
+ return await this.hotStateManager.hget(["instance-states", projectId], instanceId)
45
+ }
46
+
47
+ async updateInstanceStates(
48
+ projectId: string,
49
+ states: InstanceState[],
50
+ persistent = false,
51
+ ): Promise<InstanceState[]> {
52
+ const batch = this.stateManager.batch()
53
+ const promises: Promise<void>[] = []
54
+
55
+ for (const state of states) {
56
+ promises.push(this.hotStateManager.hset(["instance-states", projectId], state.id, state))
57
+
58
+ promises.push(
59
+ this.pubsubManager.publish(["instance-state", projectId], { type: "updated", state }),
60
+ )
61
+
62
+ if (persistent) {
63
+ promises.push(this.stateManager.getInstanceStateRepository(projectId).putItem(state, batch))
64
+ }
65
+ }
66
+
67
+ await Promise.allSettled(promises)
68
+ await batch.write()
69
+
70
+ this.logger.debug({ projectId, states }, `instance states updated for project "%s"`, projectId)
71
+
72
+ return states
73
+ }
74
+
75
+ async patchInstanceState(projectId: string, patch: InstanceStatePatch): Promise<InstanceState> {
76
+ return await this.lockManager.acquire(["instance-state", projectId, patch.id], async () => {
77
+ const state = await this.hotStateManager.hget(["instance-states", projectId], patch.id)
78
+
79
+ if (!state) {
80
+ throw new Error(`Instance state with ID "${patch.id}" not found in project "${projectId}"`)
81
+ }
82
+
83
+ const patchedState = await this.applyStatePatch(
84
+ projectId,
85
+ state,
86
+ patch,
87
+ patch.operationStatus !== undefined,
88
+ )
89
+
90
+ this.logger.debug(
91
+ { projectId, instanceId: patch.id, patch },
92
+ `instance state updated for instance "%s" in project "%s"`,
93
+ patch.id,
94
+ projectId,
95
+ )
96
+
97
+ return patchedState
98
+ })
99
+ }
100
+
101
+ async patchOperationInstanceState(
102
+ projectId: string,
103
+ operation: Operation,
104
+ patch: InstanceStatePatch,
105
+ ): Promise<InstanceState> {
106
+ return await this.lockManager.acquire(["instance-state", projectId, patch.id], async () => {
107
+ const state = await this.hotStateManager.hget(["instance-states", projectId], patch.id)
108
+
109
+ const patchedState = await this.applyStatePatch(
110
+ projectId,
111
+ state,
112
+ patch,
113
+ operation.type !== "preview",
114
+ )
115
+
116
+ this.logger.debug(
117
+ { projectId, instanceId: patch.id, patch },
118
+ `instance state updated for instance "%s" in project "%s"`,
119
+ patch.id,
120
+ projectId,
121
+ )
122
+
123
+ return patchedState
124
+ })
125
+ }
126
+
127
+ async updateStateSecretNames(
128
+ projectId: string,
129
+ instanceId: string,
130
+ secretNamesToAdd: string[],
131
+ secretNamesToRemove: string[],
132
+ invalidateState = true,
133
+ ): Promise<void> {
134
+ await this.lockManager.acquire(["instance-state", projectId, instanceId], async () => {
135
+ const state = await this.hotStateManager.hget(["instance-states", projectId], instanceId)
136
+
137
+ const secretNameSet = new Set(state?.secretNames ?? [])
138
+ for (const name of secretNamesToAdd) {
139
+ secretNameSet.add(name)
140
+ }
141
+
142
+ for (const name of secretNamesToRemove) {
143
+ secretNameSet.delete(name)
144
+ }
145
+
146
+ const patch: InstanceStatePatch = {
147
+ id: instanceId,
148
+ secretNames: Array.from(secretNameSet),
149
+ }
150
+
151
+ if (state?.operationStatus && invalidateState) {
152
+ // invalidate the input hash nonce if the operation status is present
153
+ // if not, the invalidation makes no sense
154
+ patch.operationStatus = {
155
+ inputHashNonce: randomBytes(4).readInt32LE(),
156
+ }
157
+ }
158
+
159
+ await this.applyStatePatch(projectId, state, patch, true)
160
+ })
161
+ }
162
+
163
+ /**
164
+ * Completely deletes the instance state from the system including all related resources.
165
+ *
166
+ * @param projectId The ID of the project from which to delete the instance state.
167
+ * @param instanceId The ID of the instance whose state is to be deleted.
168
+ */
169
+ async deleteInstanceState(projectId: string, instanceId: string): Promise<void> {
170
+ await this.lockManager.acquire(["instance-state", projectId, instanceId], async () => {
171
+ const [instanceType, instanceName] = parseInstanceId(instanceId)
172
+ const state = await this.getInstanceState(projectId, instanceId)
173
+
174
+ const batch = this.stateManager.batch()
175
+
176
+ await Promise.allSettled([
177
+ // remove from the state
178
+ this.stateManager.getInstanceStateRepository(projectId).delete(instanceId, batch),
179
+
180
+ // clear related resources
181
+ this.stateManager.getInstanceLockRepository(projectId).delete(instanceId, batch),
182
+
183
+ this.stateManager
184
+ .getPageRepository(projectId)
185
+ .deleteMany(Object.values(state?.extra?.pageIds ?? {}), batch),
186
+
187
+ this.stateManager
188
+ .getTriggerRepository(projectId)
189
+ .deleteMany(Object.values(state?.extra?.triggerIds ?? {}), batch),
190
+
191
+ this.stateManager
192
+ .getTerminalRepository(projectId)
193
+ .deleteMany(Object.values(state?.extra?.terminalIds ?? {}), batch),
194
+ ])
195
+
196
+ await Promise.allSettled([
197
+ // commit the state batch
198
+ batch.write(),
199
+
200
+ // update hotstate
201
+ this.hotStateManager.hdel(["instance-states", projectId], instanceId),
202
+
203
+ // publish the instance state deletion event
204
+ this.pubsubManager.publish(["instance-state", projectId], { type: "deleted", instanceId }),
205
+
206
+ // remove all artifact references
207
+ this.artifactManager.removeUsages(
208
+ projectId,
209
+ unique([
210
+ ...Object.values(state?.extra?.ownedArtifactIds ?? []).flat(),
211
+ ...(state?.extra?.usedArtifactIds ?? []),
212
+ ]),
213
+ [{ type: "instance", instanceId }],
214
+ ),
215
+
216
+ // clear pulumi state
217
+ this.runnerBackend.deleteState({ projectId, instanceName, instanceType }),
218
+ ])
219
+ })
220
+ }
221
+
222
+ async updateCustomStatus(
223
+ projectId: string,
224
+ instanceId: string,
225
+ status: InstanceCustomStatus,
226
+ ): Promise<void> {
227
+ await this.lockManager.acquire(["instance-state", projectId, instanceId], async () => {
228
+ const state = await this.hotStateManager.hget(["instance-states", projectId], instanceId)
229
+ if (!state) {
230
+ throw new Error(
231
+ `Instance state with ID "${instanceId}" not found in project "${projectId}"`,
232
+ )
233
+ }
234
+
235
+ const existingStatus = state.extra?.customStatuses?.find(s => s.name === status.name)
236
+ if (existingStatus) {
237
+ Object.assign(existingStatus, status)
238
+ } else {
239
+ state.extra ??= {}
240
+ state.extra.customStatuses ??= []
241
+ state.extra.customStatuses?.push(status)
242
+ }
243
+
244
+ await this.applyStatePatch(projectId, state, { id: instanceId, extra: state.extra })
245
+ })
246
+ }
247
+
248
+ private async applyStatePatch(
249
+ projectId: string,
250
+ state: InstanceState | null,
251
+ patch: InstanceStatePatch,
252
+ persistent = true,
253
+ ): Promise<InstanceState> {
254
+ const patchedState = applyStatePatch(state, patch)
255
+
256
+ const promises: Promise<void>[] = [
257
+ this.hotStateManager.hset(["instance-states", projectId], patchedState.id, patchedState),
258
+ ]
259
+
260
+ const shouldPersist =
261
+ persistent &&
262
+ (!patchedState.operationStatus ||
263
+ isStableOperationStatus(patchedState.operationStatus?.status))
264
+
265
+ const repo = this.stateManager.getInstanceStateRepository(projectId)
266
+
267
+ if (isStateEmpty(patchedState)) {
268
+ promises.push(
269
+ this.pubsubManager.publish(["instance-state", projectId], {
270
+ type: "deleted",
271
+ instanceId: patchedState.id,
272
+ }),
273
+ )
274
+
275
+ if (shouldPersist) {
276
+ promises.push(repo.delete(patchedState.id))
277
+ }
278
+ } else {
279
+ promises.push(
280
+ this.pubsubManager.publish(["instance-state", projectId], { type: "patched", patch }),
281
+ )
282
+
283
+ if (shouldPersist) {
284
+ promises.push(repo.putItem(patchedState))
285
+ }
286
+ }
287
+
288
+ await Promise.allSettled(promises)
289
+
290
+ return patchedState
291
+ }
292
+ }
@@ -0,0 +1,251 @@
1
+ import type { Logger } from "pino"
2
+ import type { PubSubManager } from "../pubsub"
3
+ import type { HotStateManager } from "../hotstate"
4
+ import { v7 as uuidv7 } from "uuid"
5
+ import { SAME_KEY, type StateManager } from "../state"
6
+ import { isFinalOperationStatus, type Operation } from "../shared"
7
+
8
+ export class OperationService {
9
+ constructor(
10
+ private readonly stateManager: StateManager,
11
+ private readonly hotStateManager: HotStateManager,
12
+ private readonly pubsubManager: PubSubManager,
13
+ private readonly logger: Logger,
14
+ ) {}
15
+
16
+ async getActiveOperaiton(projectId: string, operationId: string): Promise<Operation | null> {
17
+ try {
18
+ return await this.hotStateManager.hget(["active-operations", projectId], operationId)
19
+ } catch (error) {
20
+ this.logger.error(
21
+ { projectId, operationId, error },
22
+ `failed to get active operation with ID "%s" in project "%s" from hot state`,
23
+ operationId,
24
+ projectId,
25
+ )
26
+
27
+ return null
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Persists an operation to the state and publishes an update event.
33
+ *
34
+ * @param projectId The project ID to which the operation belongs.
35
+ * @param operation The operation to persist.
36
+ */
37
+ async updateOperation(projectId: string, operation: Operation): Promise<void> {
38
+ // 1. update the operation in the hot state
39
+ try {
40
+ if (isFinalOperationStatus(operation.status)) {
41
+ await this.hotStateManager.hdel(["active-operations", projectId], operation.id)
42
+ } else {
43
+ await this.hotStateManager.hset(["active-operations", projectId], operation.id, operation)
44
+ }
45
+ } catch (error) {
46
+ this.logger.error(
47
+ { projectId, operationId: operation.id, error },
48
+ `failed to update operation with ID "%s" in project "%s" in hot state`,
49
+ operation.id,
50
+ projectId,
51
+ )
52
+ }
53
+
54
+ // 2. persist the operation in the state repository
55
+ try {
56
+ await using batch = this.stateManager.batch()
57
+
58
+ // persist the operation to the operation repository
59
+ await this.stateManager.getOperationRepository(projectId).put(operation.id, operation, batch)
60
+
61
+ if (operation.status === "pending") {
62
+ // add the just created operation to the active operations index
63
+ await this.stateManager
64
+ .getActiveOperationIndexRepository(projectId)
65
+ .indexRepository.put(operation.id, operation.id, batch)
66
+ } else if (isFinalOperationStatus(operation.status)) {
67
+ // remove the finished operation from the active operations index
68
+ await this.stateManager
69
+ .getActiveOperationIndexRepository(projectId)
70
+ .indexRepository.delete(operation.id, batch)
71
+ }
72
+
73
+ await batch.write()
74
+
75
+ try {
76
+ await this.pubsubManager.publish(["operation", operation.id], {
77
+ type: "updated",
78
+ operation,
79
+ })
80
+ } catch (error) {
81
+ this.logger.error(
82
+ { projectId, operationId: operation.id, error },
83
+ `failed to publish operation update for operation with ID "%s" in project "%s"`,
84
+ operation.id,
85
+ projectId,
86
+ )
87
+ }
88
+
89
+ this.logger.info(
90
+ { projectId, operationId: operation.id },
91
+ `updated operation with ID "%s" in project "%s"`,
92
+ operation.id,
93
+ projectId,
94
+ )
95
+ } catch (error) {
96
+ this.logger.error(
97
+ { projectId, operationId: operation.id, error },
98
+ `failed to update operation with ID "%s" in project "%s"`,
99
+ operation.id,
100
+ projectId,
101
+ )
102
+
103
+ throw new Error(`Failed to update operation "${operation.id}" in project "${projectId}"`, {
104
+ cause: error,
105
+ })
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Appends logs for a specific operation.
111
+ * Creates all necessary indexes to retrieve the logs by instance ID.
112
+ *
113
+ * @param projectId The ID of the project to persist the logs for.
114
+ * @param operationId The ID of the operation to persist the logs for.
115
+ * @param instanceIds The IDs of the instances to persist the logs for. Should include the whole hierarchy of instances produced the logs.
116
+ * @param logs The logs entries to persist.
117
+ */
118
+ async appendLogs(
119
+ projectId: string,
120
+ operationId: string,
121
+ instanceIds: string[],
122
+ logs: string[],
123
+ ): Promise<void> {
124
+ const records: [string, string][] = logs.map(log => [uuidv7(), log])
125
+
126
+ // 1. publish logs to the pubsub system, ignore errors
127
+ try {
128
+ const events = instanceIds.flatMap(instanceId =>
129
+ records.map(
130
+ record => [[`operation-instance-log`, operationId, instanceId], record] as const,
131
+ ),
132
+ )
133
+
134
+ await this.pubsubManager.publishMany(events)
135
+ } catch (error) {
136
+ this.logger.error(
137
+ { projectId, operationId, instanceIds, error },
138
+ `failed to publish logs for operation "%s" in project "%s"`,
139
+ operationId,
140
+ projectId,
141
+ )
142
+ }
143
+
144
+ // 2. persist logs to the hot state and state repository, ignore errors
145
+ try {
146
+ // insert logs into the hotstate
147
+ await this.hotStateManager.hmset(["operation-logs", projectId], records)
148
+ } catch (error) {
149
+ this.logger.error(
150
+ { projectId, operationId, instanceIds, error },
151
+ `failed to append logs for operation "%s" in project "%s"`,
152
+ operationId,
153
+ projectId,
154
+ )
155
+ }
156
+
157
+ // 3. persist logs to the state repository, fail on error
158
+ try {
159
+ await using batch = this.stateManager.batch()
160
+
161
+ // insert logs into the operation log repository
162
+ await this.stateManager
163
+ .getOperationLogRepository(projectId, operationId)
164
+ .putMany(records, batch)
165
+
166
+ for (const instanceId of instanceIds) {
167
+ // create an index for the instance logs
168
+ await this.stateManager
169
+ .getInstanceLogIndexRepository(projectId, operationId, instanceId)
170
+ .indexRepository.putMany(
171
+ records.map(([id]) => [id, SAME_KEY]),
172
+ batch,
173
+ )
174
+ }
175
+
176
+ await batch.write()
177
+ } catch (error) {
178
+ this.logger.error(
179
+ { projectId, operationId, instanceIds, error },
180
+ `failed to persist instance logs for operation "%s" in project "%s"`,
181
+ operationId,
182
+ projectId,
183
+ )
184
+
185
+ throw new Error(
186
+ `Failed to persist logs for operation "${operationId}" in project "${projectId}"`,
187
+ { cause: error },
188
+ )
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Retrieves logs for a specific operation and instance.
194
+ * First tries to get logs from the hot state, then falls back to the state repository.
195
+ *
196
+ * @param projectId The ID of the project to retrieve logs for.
197
+ * @param operationId The ID of the operation to retrieve logs for.
198
+ * @param instanceId The ID of the instance to retrieve logs for.
199
+ * @returns An array of log entries as tuples of [logId, logContent].
200
+ */
201
+ async getInstanceLogs(
202
+ projectId: string,
203
+ operationId: string,
204
+ instanceId: string,
205
+ ): Promise<[string, string][]> {
206
+ // 1. first try to get logs from the hot state
207
+ try {
208
+ const logs = await this.hotStateManager.hgetall(["operation-logs", projectId])
209
+
210
+ if (logs.length > 0) {
211
+ // if there are logs, assume they are up-to-date
212
+ return logs
213
+ }
214
+
215
+ // otherwise, check if the operation is active to determine if logs are expected
216
+ const hasActiveOperation = await this.hotStateManager.hexists(
217
+ ["active-operations", projectId],
218
+ operationId,
219
+ )
220
+
221
+ if (hasActiveOperation) {
222
+ // if the operation is active, there is just no logs yet
223
+ return []
224
+ }
225
+ } catch (error) {
226
+ this.logger.error(
227
+ { projectId, operationId, instanceId, error },
228
+ `failed to get logs for operation "%s" in project "%s"`,
229
+ operationId,
230
+ projectId,
231
+ )
232
+ }
233
+
234
+ // 2. if not found or failed, try to get logs from the state repository
235
+ try {
236
+ // if no logs are found in the hot state, load them from the state repository
237
+ return await this.stateManager
238
+ .getInstanceLogIndexRepository(projectId, operationId, instanceId)
239
+ .getAll()
240
+ } catch (error) {
241
+ this.logger.error(
242
+ { projectId, operationId, instanceId, error },
243
+ `failed to get logs for operation "%s" in project "%s"`,
244
+ operationId,
245
+ projectId,
246
+ )
247
+
248
+ return []
249
+ }
250
+ }
251
+ }