@highstate/backend 0.9.16 → 0.9.19

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 (302) hide show
  1. package/dist/chunk-5WVU2AK4.js +1535 -0
  2. package/dist/chunk-5WVU2AK4.js.map +1 -0
  3. package/dist/chunk-I7BWSAN6.js +49 -0
  4. package/dist/chunk-I7BWSAN6.js.map +1 -0
  5. package/dist/{chunk-RCB4AFGD.js → chunk-VB4YL327.js} +51 -71
  6. package/dist/chunk-VB4YL327.js.map +1 -0
  7. package/dist/database/local/prisma.config.js +26 -0
  8. package/dist/database/local/prisma.config.js.map +1 -0
  9. package/dist/highstate.manifest.json +5 -4
  10. package/dist/index.js +7676 -6634
  11. package/dist/index.js.map +1 -1
  12. package/dist/library/package-resolution-worker.js +8 -6
  13. package/dist/library/package-resolution-worker.js.map +1 -1
  14. package/dist/library/worker/main.js +63 -58
  15. package/dist/library/worker/main.js.map +1 -1
  16. package/dist/shared/index.js +3 -216
  17. package/dist/shared/index.js.map +1 -1
  18. package/package.json +23 -11
  19. package/prisma/backend/_schema/layout.prisma +7 -0
  20. package/prisma/backend/_schema/library.prisma +17 -0
  21. package/prisma/backend/_schema/project.prisma +101 -0
  22. package/prisma/backend/_schema/pulumi.prisma +17 -0
  23. package/prisma/backend/postgresql/main.prisma +17 -0
  24. package/prisma/backend/sqlite/main.prisma +17 -0
  25. package/prisma/backend/sqlite/migrations/20250817070609_initiial/migration.sql +34 -0
  26. package/prisma/backend/sqlite/migrations/20250817104948_add_fields/migration.sql +59 -0
  27. package/prisma/backend/sqlite/migrations/20250818082732_add_models/migration.sql +41 -0
  28. package/prisma/backend/sqlite/migrations/20250818083106_a/migration.sql +19 -0
  29. package/prisma/backend/sqlite/migrations/20250818101945_hi/migration.sql +1 -0
  30. package/prisma/backend/sqlite/migrations/20250819082315_a/migration.sql +5 -0
  31. package/prisma/backend/sqlite/migrations/migration_lock.toml +3 -0
  32. package/prisma/project/api-key.prisma +27 -0
  33. package/prisma/project/artifact.prisma +52 -0
  34. package/prisma/project/custom-status.prisma +46 -0
  35. package/prisma/project/evaluation.prisma +35 -0
  36. package/prisma/project/instance.prisma +160 -0
  37. package/prisma/project/layout.prisma +23 -0
  38. package/prisma/project/lock.prisma +18 -0
  39. package/prisma/project/main.prisma +17 -0
  40. package/prisma/project/migrations/20250816081310_initial/migration.sql +300 -0
  41. package/prisma/project/migrations/20250816082523_test/migration.sql +72 -0
  42. package/prisma/project/migrations/20250818065643_update/migration.sql +42 -0
  43. package/prisma/project/migrations/20250818070758_a/migration.sql +8 -0
  44. package/prisma/project/migrations/20250818070913_a/migration.sql +8 -0
  45. package/prisma/project/migrations/20250818082720_add_motels/migration.sql +11 -0
  46. package/prisma/project/migrations/20250818112523_hello/migration.sql +35 -0
  47. package/prisma/project/migrations/20250819082305_a/migration.sql +14 -0
  48. package/prisma/project/migrations/20250819165004_add_missing_fields/migration.sql +216 -0
  49. package/prisma/project/migrations/20250819171309_a/migration.sql +22 -0
  50. package/prisma/project/migrations/20250820113949_a/migration.sql +66 -0
  51. package/prisma/project/migrations/20250820144256_b/migration.sql +31 -0
  52. package/prisma/project/migrations/20250820145547_a/migration.sql +24 -0
  53. package/prisma/project/migrations/20250820182517_b/migration.sql +2 -0
  54. package/prisma/project/migrations/20250821172324_a/migration.sql +2 -0
  55. package/prisma/project/migrations/20250822081339_a/migration.sql +219 -0
  56. package/prisma/project/migrations/20250822083742_b/migration.sql +1 -0
  57. package/prisma/project/migrations/20250822105134_boom/migration.sql +1 -0
  58. package/prisma/project/migrations/20250822141028_b/migration.sql +1 -0
  59. package/prisma/project/migrations/20250822142342_b/migration.sql +16 -0
  60. package/prisma/project/migrations/20250824072720_a/migration.sql +1 -0
  61. package/prisma/project/migrations/20250824093656_b/migration.sql +21 -0
  62. package/prisma/project/migrations/20250825082518_a/migration.sql +1 -0
  63. package/prisma/project/migrations/20250825085343_b/migration.sql +1 -0
  64. package/prisma/project/migrations/20250825091312_a/migration.sql +1 -0
  65. package/prisma/project/migrations/20250903095431_hi/migration.sql +44 -0
  66. package/prisma/project/migrations/20250903174255_a/migration.sql +24 -0
  67. package/prisma/project/migrations/20250908095205_hi/migration.sql +18 -0
  68. package/prisma/project/migrations/20250909155857_hi/migration.sql +15 -0
  69. package/prisma/project/migrations/migration_lock.toml +3 -0
  70. package/prisma/project/model.prisma +37 -0
  71. package/prisma/project/operation.prisma +148 -0
  72. package/prisma/project/page.prisma +41 -0
  73. package/prisma/project/secret.prisma +42 -0
  74. package/prisma/project/service-account.prisma +36 -0
  75. package/prisma/project/terminal.prisma +90 -0
  76. package/prisma/project/trigger.prisma +31 -0
  77. package/prisma/project/unlock-method.prisma +32 -0
  78. package/prisma/project/worker.prisma +138 -0
  79. package/src/artifact/abstractions.ts +13 -13
  80. package/src/artifact/encryption.ts +31 -15
  81. package/src/artifact/factory.ts +7 -10
  82. package/src/artifact/local.ts +33 -50
  83. package/src/business/api-key.ts +24 -36
  84. package/src/business/artifact.test.ts +978 -0
  85. package/src/business/artifact.ts +136 -215
  86. package/src/business/evaluation.ts +328 -0
  87. package/src/business/index.ts +5 -1
  88. package/src/business/instance-lock.test.ts +1060 -0
  89. package/src/business/instance-lock.ts +387 -77
  90. package/src/business/instance-state.test.ts +735 -0
  91. package/src/business/instance-state.ts +604 -217
  92. package/src/business/operation.test.ts +439 -0
  93. package/src/business/operation.ts +174 -208
  94. package/src/business/project-model.ts +258 -0
  95. package/src/business/project-unlock.ts +172 -112
  96. package/src/business/project.ts +407 -0
  97. package/src/business/secret.test.ts +513 -0
  98. package/src/business/secret.ts +194 -131
  99. package/src/business/settings.test.ts +695 -0
  100. package/src/business/settings.ts +855 -0
  101. package/src/business/terminal-session.ts +90 -0
  102. package/src/business/unit-extra.test.ts +539 -0
  103. package/src/business/unit-extra.ts +160 -0
  104. package/src/business/worker.test.ts +391 -0
  105. package/src/business/worker.ts +250 -114
  106. package/src/common/codebase.ts +65 -0
  107. package/src/common/index.ts +3 -2
  108. package/src/common/logger.ts +5 -0
  109. package/src/common/utils.ts +4 -3
  110. package/src/config.ts +15 -12
  111. package/src/database/_generated/backend/postgresql/client.ts +72 -0
  112. package/src/database/_generated/backend/postgresql/commonInputTypes.ts +350 -0
  113. package/src/database/_generated/backend/postgresql/enums.ts +13 -0
  114. package/src/database/_generated/backend/postgresql/internal/class.ts +320 -0
  115. package/src/database/_generated/backend/postgresql/internal/prismaNamespace.ts +1238 -0
  116. package/src/database/_generated/backend/postgresql/models/Library.ts +1263 -0
  117. package/src/database/_generated/backend/postgresql/models/Project.ts +2175 -0
  118. package/src/database/_generated/backend/postgresql/models/ProjectModelStorage.ts +1263 -0
  119. package/src/database/_generated/backend/postgresql/models/ProjectSpace.ts +1602 -0
  120. package/src/database/_generated/backend/postgresql/models/PulumiBackend.ts +1263 -0
  121. package/src/database/_generated/backend/postgresql/models/UserWorkspaseLayout.ts +1065 -0
  122. package/src/database/_generated/backend/postgresql/models.ts +16 -0
  123. package/src/database/_generated/backend/postgresql/pjtg.ts +182 -0
  124. package/src/database/_generated/backend/sqlite/client.ts +72 -0
  125. package/src/database/_generated/backend/sqlite/commonInputTypes.ts +331 -0
  126. package/src/database/_generated/backend/sqlite/enums.ts +13 -0
  127. package/src/database/_generated/backend/sqlite/internal/class.ts +318 -0
  128. package/src/database/_generated/backend/sqlite/internal/prismaNamespace.ts +1207 -0
  129. package/src/database/_generated/backend/sqlite/models/Library.ts +1261 -0
  130. package/src/database/_generated/backend/sqlite/models/Project.ts +2169 -0
  131. package/src/database/_generated/backend/sqlite/models/ProjectModelStorage.ts +1261 -0
  132. package/src/database/_generated/backend/sqlite/models/ProjectSpace.ts +1599 -0
  133. package/src/database/_generated/backend/sqlite/models/PulumiBackend.ts +1261 -0
  134. package/src/database/_generated/backend/sqlite/models/UserWorkspaseLayout.ts +1063 -0
  135. package/src/database/_generated/backend/sqlite/models.ts +16 -0
  136. package/src/database/_generated/backend/sqlite/pjtg.ts +182 -0
  137. package/src/database/_generated/project/client.ts +204 -0
  138. package/src/database/_generated/project/commonInputTypes.ts +827 -0
  139. package/src/database/_generated/project/enums.ts +104 -0
  140. package/src/database/_generated/project/internal/class.ts +479 -0
  141. package/src/database/_generated/project/internal/prismaNamespace.ts +2974 -0
  142. package/src/database/_generated/project/models/ApiKey.ts +1506 -0
  143. package/src/database/_generated/project/models/Artifact.ts +2051 -0
  144. package/src/database/_generated/project/models/HubModel.ts +1125 -0
  145. package/src/database/_generated/project/models/InstanceCustomStatus.ts +1713 -0
  146. package/src/database/_generated/project/models/InstanceEvaluationState.ts +1312 -0
  147. package/src/database/_generated/project/models/InstanceLock.ts +1268 -0
  148. package/src/database/_generated/project/models/InstanceModel.ts +1125 -0
  149. package/src/database/_generated/project/models/InstanceOperationState.ts +1707 -0
  150. package/src/database/_generated/project/models/InstanceState.ts +4613 -0
  151. package/src/database/_generated/project/models/Operation.ts +1647 -0
  152. package/src/database/_generated/project/models/OperationLog.ts +1455 -0
  153. package/src/database/_generated/project/models/Page.ts +1838 -0
  154. package/src/database/_generated/project/models/Secret.ts +1692 -0
  155. package/src/database/_generated/project/models/ServiceAccount.ts +2165 -0
  156. package/src/database/_generated/project/models/Terminal.ts +2038 -0
  157. package/src/database/_generated/project/models/TerminalSession.ts +1454 -0
  158. package/src/database/_generated/project/models/TerminalSessionLog.ts +1280 -0
  159. package/src/database/_generated/project/models/Trigger.ts +1430 -0
  160. package/src/database/_generated/project/models/UnlockMethod.ts +1220 -0
  161. package/src/database/_generated/project/models/UserCompositeViewport.ts +1280 -0
  162. package/src/database/_generated/project/models/UserProjectViewport.ts +1059 -0
  163. package/src/database/_generated/project/models/Worker.ts +1459 -0
  164. package/src/database/_generated/project/models/WorkerUnitRegistration.ts +1524 -0
  165. package/src/database/_generated/project/models/WorkerVersion.ts +1974 -0
  166. package/src/database/_generated/project/models/WorkerVersionLog.ts +1318 -0
  167. package/src/database/_generated/project/models.ts +35 -0
  168. package/src/database/_generated/project/pjtg.ts +182 -0
  169. package/src/database/abstractions.ts +19 -0
  170. package/src/database/factory.ts +37 -0
  171. package/src/database/index.ts +6 -0
  172. package/src/database/local/backend.ts +134 -0
  173. package/src/database/local/index.ts +3 -0
  174. package/src/database/local/meta.ts +46 -0
  175. package/src/database/local/prisma.config.ts +25 -0
  176. package/src/database/local/project.ts +39 -0
  177. package/src/database/manager.ts +181 -0
  178. package/src/database/migrate.ts +35 -0
  179. package/src/database/prisma.ts +56 -0
  180. package/src/database/well-known.ts +38 -0
  181. package/src/index.ts +4 -4
  182. package/src/library/abstractions.ts +21 -14
  183. package/src/library/factory.ts +1 -1
  184. package/src/library/local.ts +86 -38
  185. package/src/library/package-resolution-worker.ts +1 -1
  186. package/src/library/worker/evaluator.ts +61 -48
  187. package/src/library/worker/loader.lite.ts +14 -1
  188. package/src/library/worker/main.ts +9 -16
  189. package/src/library/worker/protocol.ts +0 -12
  190. package/src/lock/manager.ts +12 -7
  191. package/src/orchestrator/manager.ts +198 -131
  192. package/src/orchestrator/operation-context.ts +357 -0
  193. package/src/orchestrator/operation-plan.destroy.test.md +357 -0
  194. package/src/orchestrator/operation-plan.destroy.test.ts +775 -0
  195. package/src/orchestrator/operation-plan.fixtures.ts +213 -0
  196. package/src/orchestrator/operation-plan.md +198 -0
  197. package/src/orchestrator/operation-plan.refresh.test.md +199 -0
  198. package/src/orchestrator/operation-plan.refresh.test.ts +367 -0
  199. package/src/orchestrator/operation-plan.ts +709 -0
  200. package/src/orchestrator/operation-plan.update.test.md +485 -0
  201. package/src/orchestrator/operation-plan.update.test.ts +1066 -0
  202. package/src/orchestrator/operation-workset.ts +235 -583
  203. package/src/orchestrator/operation.ts +446 -904
  204. package/src/orchestrator/plan-test-builder.ts +267 -0
  205. package/src/project-model/abstractions.ts +118 -0
  206. package/src/project-model/backends/codebase.ts +365 -0
  207. package/src/project-model/backends/database.ts +440 -0
  208. package/src/project-model/errors.ts +81 -0
  209. package/src/project-model/factory.ts +24 -0
  210. package/src/project-model/index.ts +4 -0
  211. package/src/project-model/utils.test.ts +544 -0
  212. package/src/project-model/utils.ts +242 -0
  213. package/src/pubsub/abstractions.ts +10 -1
  214. package/src/pubsub/factory.ts +4 -4
  215. package/src/pubsub/index.ts +1 -0
  216. package/src/pubsub/manager.ts +49 -25
  217. package/src/pubsub/memory.ts +31 -0
  218. package/src/runner/abstractions.ts +38 -26
  219. package/src/runner/artifact-env.ts +17 -6
  220. package/src/runner/factory.ts +6 -6
  221. package/src/runner/force-abort.ts +3 -6
  222. package/src/runner/local.ts +79 -72
  223. package/src/runner/pulumi.ts +26 -63
  224. package/src/services.ts +214 -103
  225. package/src/shared/models/backend/index.ts +3 -1
  226. package/src/shared/models/backend/library.ts +12 -4
  227. package/src/shared/models/backend/project.ts +43 -23
  228. package/src/shared/models/backend/pulumi.ts +14 -0
  229. package/src/shared/models/backend/unlock-method.ts +1 -1
  230. package/src/shared/models/backend/well-known.ts +58 -0
  231. package/src/shared/models/base.ts +40 -109
  232. package/src/shared/models/errors.ts +82 -1
  233. package/src/shared/models/index.ts +3 -2
  234. package/src/shared/models/prisma.ts +36 -0
  235. package/src/shared/models/project/api-key.ts +37 -56
  236. package/src/shared/models/project/artifact.ts +15 -105
  237. package/src/shared/models/project/custom-status.ts +12 -0
  238. package/src/shared/models/project/index.ts +9 -9
  239. package/src/shared/models/project/lock.ts +10 -78
  240. package/src/shared/models/project/model.ts +32 -0
  241. package/src/shared/models/project/operation.ts +222 -99
  242. package/src/shared/models/project/page.ts +37 -48
  243. package/src/shared/models/project/secret.ts +29 -103
  244. package/src/shared/models/project/service-account.ts +12 -17
  245. package/src/shared/models/project/state.ts +100 -390
  246. package/src/shared/models/project/terminal.ts +75 -89
  247. package/src/shared/models/project/trigger.ts +13 -49
  248. package/src/shared/models/project/unlock-method.ts +21 -20
  249. package/src/shared/models/project/worker.ts +89 -88
  250. package/src/shared/resolvers/graph-resolver.ts +62 -26
  251. package/src/shared/resolvers/index.ts +1 -1
  252. package/src/shared/resolvers/input-hash.ts +24 -14
  253. package/src/shared/resolvers/input.ts +48 -6
  254. package/src/shared/resolvers/registry.ts +5 -4
  255. package/src/shared/resolvers/state.ts +12 -1
  256. package/src/shared/resolvers/validation.ts +29 -9
  257. package/src/shared/utils/index.ts +1 -1
  258. package/src/shared/utils/promise-tracker.ts +30 -3
  259. package/src/terminal/abstractions.ts +1 -1
  260. package/src/terminal/docker.ts +3 -3
  261. package/src/terminal/manager.ts +102 -118
  262. package/src/test-utils/database.ts +119 -0
  263. package/src/test-utils/index.ts +2 -0
  264. package/src/test-utils/services.ts +134 -0
  265. package/src/unlock/abstractions.ts +31 -0
  266. package/src/unlock/index.ts +2 -0
  267. package/src/unlock/memory.ts +27 -0
  268. package/src/worker/abstractions.ts +7 -4
  269. package/src/worker/docker.ts +14 -19
  270. package/src/worker/manager.ts +376 -79
  271. package/dist/chunk-RCB4AFGD.js.map +0 -1
  272. package/dist/chunk-WHALQHEZ.js +0 -2017
  273. package/dist/chunk-WHALQHEZ.js.map +0 -1
  274. package/src/business/backend-unlock.ts +0 -10
  275. package/src/common/performance.ts +0 -44
  276. package/src/hotstate/abstractions.ts +0 -48
  277. package/src/hotstate/factory.ts +0 -17
  278. package/src/hotstate/index.ts +0 -3
  279. package/src/hotstate/manager.ts +0 -192
  280. package/src/hotstate/memory.ts +0 -100
  281. package/src/hotstate/validation.ts +0 -101
  282. package/src/project/abstractions.ts +0 -102
  283. package/src/project/factory.ts +0 -11
  284. package/src/project/index.ts +0 -3
  285. package/src/project/local.ts +0 -469
  286. package/src/project/manager.ts +0 -574
  287. package/src/pubsub/local.ts +0 -36
  288. package/src/pubsub/validation.ts +0 -33
  289. package/src/shared/models/project/component.ts +0 -45
  290. package/src/shared/models/project/instance.ts +0 -74
  291. package/src/state/abstractions.ts +0 -450
  292. package/src/state/encryption.ts +0 -59
  293. package/src/state/factory.ts +0 -20
  294. package/src/state/index.ts +0 -6
  295. package/src/state/local/backend.ts +0 -299
  296. package/src/state/local/collection.ts +0 -342
  297. package/src/state/local/index.ts +0 -2
  298. package/src/state/manager.ts +0 -819
  299. package/src/state/repository/index.ts +0 -2
  300. package/src/state/repository/repository.index.ts +0 -193
  301. package/src/state/repository/repository.ts +0 -458
  302. /package/src/{state → database/local}/keyring.ts +0 -0
@@ -1,124 +1,434 @@
1
+ import type { CommonObjectMeta } from "@highstate/contract"
1
2
  import type { Logger } from "pino"
2
- import type { LockManager } from "../lock"
3
+ import type { DatabaseManager, InstanceLock, ProjectTransaction } from "../database"
3
4
  import type { PubSubManager } from "../pubsub"
4
- import type { StateManager } from "../state"
5
- import { compareInstanceLockSpecs, type InstanceLockSpec, type ObjectMeta } from "../shared"
5
+ import { createId } from "@paralleldrive/cuid2"
6
+ import { type InstanceLockEvent, InstanceLockLostError } from "../shared"
6
7
 
8
+ /**
9
+ * Service for managing instance locks within projects.
10
+ * Handles atomic lock operations using database transactions.
11
+ */
7
12
  export class InstanceLockService {
8
13
  constructor(
9
- private readonly stateManager: StateManager,
10
- private readonly lockManager: LockManager,
14
+ private readonly database: DatabaseManager,
11
15
  private readonly pubsubManager: PubSubManager,
12
16
  private readonly logger: Logger,
13
17
  ) {}
14
18
 
15
19
  /**
16
- * Tries to lock instances in the project.
17
- * Returns true if the lock was acquired successfully, false otherwise.
20
+ * Attempts to acquire locks on the specified instances.
21
+ * Uses database transactions to ensure atomicity.
18
22
  *
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.
23
+ * @param projectId The project ID containing the instances.
24
+ * @param stateIds The instance state IDs to lock.
25
+ * @param lockMeta The metadata for the locks.
26
+ * @param action Optional async action to execute once locks are acquired in the same transaction with the locks.
27
+ * @param allowPartialLock Whether to allow partial locking when some instances are already locked.
28
+ * @param customToken Optional custom token to use instead of auto-generating one.
29
+ * @returns A tuple containing the token and array of successfully locked state IDs.
30
30
  */
31
31
  async tryLockInstances(
32
32
  projectId: string,
33
- instanceIds: string[],
34
- lockMeta: ObjectMeta,
35
- spec: InstanceLockSpec,
33
+ stateIds: string[],
34
+ lockMeta: CommonObjectMeta,
35
+ action?: (tx: ProjectTransaction, stateIds: string[]) => Promise<void>,
36
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
- )
37
+ customToken?: string,
38
+ ): Promise<[token: string, lockedStateIds: string[]]> {
39
+ if (stateIds.length === 0) {
40
+ return ["", []]
41
+ }
51
42
 
52
- if (conflictingLocks.length > 0) {
43
+ // use custom token or generate a cuid token for this lock operation
44
+ const token = customToken ?? createId()
45
+ const database = await this.database.forProject(projectId)
46
+
47
+ return await database.$transaction(async tx => {
48
+ // check for existing locks on requested instances
49
+ const existingLocks = await tx.instanceLock.findMany({
50
+ where: { stateId: { in: stateIds } },
51
+ select: { stateId: true },
52
+ })
53
+
54
+ const lockedStateIds = existingLocks.map(lock => lock.stateId)
55
+ const availableStateIds = stateIds.filter(id => !lockedStateIds.includes(id))
56
+
57
+ if (lockedStateIds.length > 0) {
53
58
  this.logger.debug(
54
59
  {
55
60
  projectId,
56
- requestedInstanceIds: instanceIds,
57
- lockedInstanceIds: conflictingLocks.map(lock => lock.id),
61
+ conflictingInstances: lockedStateIds.length,
62
+ totalRequested: stateIds.length,
58
63
  },
59
- `failed to lock %s of %s instances in project "%s"`,
60
- conflictingLocks.length,
61
- instanceIds.length,
62
- projectId,
64
+ "found %s conflicting locks when attempting to lock %s instances",
65
+ lockedStateIds.length,
66
+ stateIds.length,
63
67
  )
64
68
 
65
69
  if (!allowPartialLock) {
66
- // if partial locking is not allowed, return false
67
- return [false, []]
70
+ return ["", []]
68
71
  }
72
+ }
69
73
 
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))
74
+ if (availableStateIds.length === 0) {
75
+ // when custom token is provided and no instances are locked, don't return the token
76
+ // when allowPartialLock is true and no custom token, return a token for consistency
77
+ return [allowPartialLock && !customToken ? token : "", []]
72
78
  }
73
79
 
74
- const newLocks = instanceIds.map(id => ({ id, projectId, meta: lockMeta, spec }))
80
+ // create locks for available instances with the generated token
81
+ const lockData: InstanceLock[] = availableStateIds.map(stateId => ({
82
+ stateId,
83
+ meta: lockMeta,
84
+ token,
85
+ acquiredAt: new Date(),
86
+ }))
87
+
88
+ await tx.instanceLock.createMany({ data: lockData })
75
89
 
76
- // persist the new locks
77
- await this.stateManager.getInstanceLockRepository(projectId).putManyItems(newLocks)
90
+ await action?.(tx, availableStateIds)
78
91
 
79
92
  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,
93
+ { projectId, lockedCount: availableStateIds.length, token },
94
+ "locked %s instances",
95
+ availableStateIds.length,
88
96
  )
89
97
 
90
- // publish the lock event
98
+ // publish lock event
91
99
  await this.pubsubManager.publish(["instance-lock", projectId], {
92
100
  type: "locked",
93
- locks: newLocks,
101
+ locks: lockData,
94
102
  })
95
103
 
96
- return [true, newLocks.map(lock => lock.id)]
104
+ return [token, availableStateIds]
97
105
  })
98
106
  }
99
107
 
100
108
  /**
101
- * Unlocks instances in the project unconditionally.
102
- * This will remove the locks regardless of their current state.
109
+ * Checks if an instance is currently locked.
103
110
  *
104
- * @param projectId The project ID to unlock instances in.
105
- * @param instanceIds The instance IDs to unlock.
111
+ * @param projectId The project ID containing the instance.
112
+ * @param stateId The instance state ID to check.
113
+ * @returns True if the instance is locked, false otherwise.
106
114
  */
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
- )
115
+ async isInstanceLocked(projectId: string, stateId: string): Promise<boolean> {
116
+ const database = await this.database.forProject(projectId)
114
117
 
115
- // remove the locks from the state
116
- await this.stateManager.getInstanceLockRepository(projectId).deleteMany(instanceIds)
118
+ const lock = await database.instanceLock.findUnique({
119
+ where: { stateId },
120
+ })
117
121
 
118
- // publish the unlock event
119
- await this.pubsubManager.publish(["instance-lock", projectId], {
120
- type: "unlocked",
121
- instanceIds,
122
+ return lock !== null
123
+ }
124
+
125
+ /**
126
+ * Removes locks from the specified instances using the provided token.
127
+ * Executes an optional unlock action within the transaction if the lock is still valid.
128
+ *
129
+ * @param projectId The project ID containing the instances.
130
+ * @param stateIds The instance state IDs to unlock.
131
+ * @param token The token that was returned when the locks were created.
132
+ * @param unlockAction Optional async action to execute within the unlock transaction if the lock is still valid.
133
+ * @throws {InstanceLockLostError} When the lock with the given token is not found.
134
+ */
135
+ async unlockInstances(
136
+ projectId: string,
137
+ stateIds: string[],
138
+ token: string,
139
+ unlockAction?: (tx: ProjectTransaction) => Promise<void>,
140
+ ): Promise<void> {
141
+ if (stateIds.length === 0) {
142
+ return
143
+ }
144
+
145
+ if (!token) {
146
+ throw new Error("Token is required to unlock instances")
147
+ }
148
+
149
+ const database = await this.database.forProject(projectId)
150
+
151
+ await database.$transaction(async tx => {
152
+ // verify that locks with the given token exist for all requested instances
153
+ const existingLocks = await tx.instanceLock.findMany({
154
+ where: {
155
+ stateId: { in: stateIds },
156
+ token: token,
157
+ },
158
+ select: { stateId: true },
159
+ })
160
+
161
+ const lockedStateIds = existingLocks.map(lock => lock.stateId)
162
+ const missingLocks = stateIds.filter(id => !lockedStateIds.includes(id))
163
+
164
+ if (missingLocks.length > 0) {
165
+ throw new InstanceLockLostError(projectId, missingLocks, token)
166
+ }
167
+
168
+ // execute the optional unlock action if provided
169
+ if (unlockAction) {
170
+ await unlockAction(tx)
171
+ }
172
+
173
+ // remove the locks
174
+ const { count } = await tx.instanceLock.deleteMany({
175
+ where: {
176
+ stateId: { in: stateIds },
177
+ token: token,
178
+ },
179
+ })
180
+
181
+ if (count > 0) {
182
+ this.logger.debug(
183
+ { projectId, unlockedCount: count, token },
184
+ "unlocked %s instances",
185
+ count,
186
+ )
187
+
188
+ // publish unlock event
189
+ await this.pubsubManager.publish(["instance-lock", projectId], {
190
+ type: "unlocked",
191
+ stateIds: lockedStateIds,
192
+ })
193
+ }
194
+ })
195
+ }
196
+
197
+ /**
198
+ * Unconditionally removes locks from the specified instances.
199
+ * This will remove locks regardless of their current state or ownership.
200
+ *
201
+ * @param projectId The project ID containing the instances.
202
+ * @param stateIds The instance state IDs to unlock.
203
+ */
204
+ async unlockInstancesUnconditionally(projectId: string, stateIds: string[]): Promise<void> {
205
+ if (stateIds.length === 0) {
206
+ return
207
+ }
208
+
209
+ const database = await this.database.forProject(projectId)
210
+
211
+ await database.$transaction(async tx => {
212
+ const { count } = await tx.instanceLock.deleteMany({
213
+ where: { stateId: { in: stateIds } },
214
+ })
215
+
216
+ if (count > 0) {
217
+ this.logger.info({ projectId, unlockedCount: count }, "unlocked %s instances", count)
218
+
219
+ // publish unlock event
220
+ await this.pubsubManager.publish(["instance-lock", projectId], {
221
+ type: "unlocked",
222
+ stateIds,
223
+ })
224
+ }
122
225
  })
123
226
  }
227
+
228
+ /**
229
+ * Attempts to acquire locks on the specified instances with retry logic.
230
+ * Subscribes to unlock events and retries lock acquisition until successful or aborted.
231
+ *
232
+ * @param projectId The project ID containing the instances.
233
+ * @param stateIds The instance state IDs to lock.
234
+ * @param lockMeta The metadata for the locks.
235
+ * @param action Optional async action to execute when instances are locked.
236
+ * @param allowPartialLock Whether to allow partial locking when some instances are already locked.
237
+ * @param abortSignal Optional abort signal to interrupt lock operations.
238
+ * @param eventWaitTime Optional time in milliseconds to wait for unlock events before retrying (default: 60000ms).
239
+ * @param customToken Optional custom token to use instead of auto-generating one.
240
+ * @returns A tuple containing the token and array of successfully locked state IDs.
241
+ */
242
+ async lockInstances(
243
+ projectId: string,
244
+ stateIds: string[],
245
+ lockMeta: CommonObjectMeta,
246
+ action?: (tx: ProjectTransaction, stateIds: string[]) => Promise<void>,
247
+ allowPartialLock = false,
248
+ abortSignal?: AbortSignal,
249
+ eventWaitTime = 60000,
250
+ customToken?: string,
251
+ ): Promise<[token: string, lockedStateIds: string[]]> {
252
+ if (stateIds.length === 0) {
253
+ return ["", []]
254
+ }
255
+
256
+ // generate a single token for all locks
257
+ const token = customToken ?? createId()
258
+
259
+ // track which instances still need to be locked
260
+ let remainingStateIds = [...stateIds]
261
+ const lockedStateIds: string[] = []
262
+
263
+ // create abort controller for managing event subscription
264
+ const subscriptionController = new AbortController()
265
+
266
+ // set up event subscription first before attempting any locks to reduce probability of missing events
267
+ const eventIterable = await this.pubsubManager.subscribe(
268
+ ["instance-lock", projectId],
269
+ subscriptionController.signal,
270
+ )
271
+
272
+ try {
273
+ while (remainingStateIds.length > 0) {
274
+ if (abortSignal?.aborted) {
275
+ throw new Error("Lock operation was aborted")
276
+ }
277
+
278
+ this.logger.debug(
279
+ {
280
+ projectId,
281
+ remainingCount: remainingStateIds.length,
282
+ lockedCount: lockedStateIds.length,
283
+ },
284
+ "attempting to lock %s remaining instances",
285
+ remainingStateIds.length,
286
+ )
287
+
288
+ // try to acquire locks on remaining instances using the same token
289
+ const [_, newlyLockedStateIds] = await this.tryLockInstances(
290
+ projectId,
291
+ remainingStateIds,
292
+ lockMeta,
293
+ action,
294
+ allowPartialLock,
295
+ token,
296
+ )
297
+
298
+ if (newlyLockedStateIds.length === 0) {
299
+ // no instances were locked, wait for unlock events
300
+ this.logger.debug(
301
+ { projectId, remainingCount: remainingStateIds.length },
302
+ "waiting for unlock events for %s remaining instances",
303
+ remainingStateIds.length,
304
+ )
305
+
306
+ await this.waitForUnlockEvent(
307
+ projectId,
308
+ remainingStateIds,
309
+ eventIterable,
310
+ abortSignal,
311
+ eventWaitTime,
312
+ )
313
+ continue
314
+ }
315
+
316
+ // remove newly locked instances from remaining list
317
+ remainingStateIds = remainingStateIds.filter(id => !newlyLockedStateIds.includes(id))
318
+ lockedStateIds.push(...newlyLockedStateIds)
319
+
320
+ // if partial locking is not allowed, we should have all instances by now
321
+ if (!allowPartialLock && remainingStateIds.length > 0) {
322
+ this.logger.error(
323
+ { projectId, remaining: remainingStateIds.length },
324
+ "partial lock not allowed but %s instances remain unlocked",
325
+ remainingStateIds.length,
326
+ )
327
+ throw new Error("Failed to acquire all required locks")
328
+ }
329
+ }
330
+
331
+ return [token, lockedStateIds]
332
+ } finally {
333
+ // clean up event subscription
334
+ subscriptionController.abort()
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Waits for an unlock event that affects any of the specified state IDs,
340
+ * or times out to trigger the next retry attempt.
341
+ *
342
+ * @param projectId The project ID to monitor for events.
343
+ * @param stateIds The state IDs we're waiting to become available.
344
+ * @param eventIterable The async iterable for event subscription.
345
+ * @param abortSignal Optional abort signal to interrupt waiting.
346
+ * @param eventWaitTime Time in milliseconds to wait before timing out and retrying.
347
+ */
348
+ private async waitForUnlockEvent(
349
+ projectId: string,
350
+ stateIds: string[],
351
+ eventIterable: AsyncIterable<InstanceLockEvent>,
352
+ abortSignal?: AbortSignal,
353
+ eventWaitTime = 60000,
354
+ ): Promise<void> {
355
+ const eventController = new AbortController()
356
+
357
+ // combine abort signals
358
+ if (abortSignal?.aborted) {
359
+ throw new Error("Lock operation was aborted")
360
+ }
361
+
362
+ const abortHandler = () => eventController.abort()
363
+ abortSignal?.addEventListener("abort", abortHandler)
364
+
365
+ try {
366
+ await Promise.race([
367
+ // timeout promise - triggers retry attempt, does not abort
368
+ new Promise<void>(resolve => {
369
+ setTimeout(() => {
370
+ this.logger.debug(
371
+ { projectId, eventWaitTime },
372
+ "unlock wait timed out after %s ms, will retry",
373
+ eventWaitTime,
374
+ )
375
+ resolve()
376
+ }, eventWaitTime)
377
+ }),
378
+
379
+ // event listener promise
380
+ this.listenForUnlockEvents(projectId, stateIds, eventIterable, eventController.signal),
381
+
382
+ // abort promise - only this can interrupt the operation
383
+ new Promise<void>((_, reject) => {
384
+ if (abortSignal) {
385
+ abortSignal.addEventListener("abort", () => {
386
+ reject(new Error("Lock operation was aborted"))
387
+ })
388
+ }
389
+ }),
390
+ ])
391
+ } finally {
392
+ eventController.abort()
393
+ abortSignal?.removeEventListener("abort", abortHandler)
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Listens for unlock events using async iteration.
399
+ *
400
+ * @param projectId The project ID to monitor for events.
401
+ * @param stateIds The state IDs we're waiting to become available.
402
+ * @param eventIterable The async iterable for event subscription.
403
+ * @param signal Abort signal to stop listening.
404
+ */
405
+ private async listenForUnlockEvents(
406
+ projectId: string,
407
+ stateIds: string[],
408
+ eventIterable: AsyncIterable<InstanceLockEvent>,
409
+ signal: AbortSignal,
410
+ ): Promise<void> {
411
+ for await (const event of eventIterable) {
412
+ if (signal.aborted) {
413
+ break
414
+ }
415
+ if (event.type !== "unlocked") {
416
+ continue // only interested in unlock events
417
+ }
418
+
419
+ const relevantUnlocks = event.stateIds.filter((id: string) => stateIds.includes(id))
420
+ if (relevantUnlocks.length === 0) {
421
+ continue // keep waiting for relevant unlocks
422
+ }
423
+
424
+ if (relevantUnlocks.length > 0) {
425
+ this.logger.debug(
426
+ { projectId, relevantUnlocks: relevantUnlocks.length },
427
+ "found relevant unlock event for %s instances",
428
+ relevantUnlocks.length,
429
+ )
430
+ return // this will resolve the promise and clean up the subscription
431
+ }
432
+ }
433
+ }
124
434
  }