@nestjs/workflows 0.0.1

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 (265) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +50 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +8 -0
  5. package/dist/core/codecs/aes-gcm-payload.codec.d.ts +42 -0
  6. package/dist/core/codecs/aes-gcm-payload.codec.js +119 -0
  7. package/dist/core/codecs/index.d.ts +2 -0
  8. package/dist/core/codecs/index.js +2 -0
  9. package/dist/core/codecs/payload-codecs.d.ts +59 -0
  10. package/dist/core/codecs/payload-codecs.js +130 -0
  11. package/dist/core/index.d.ts +16 -0
  12. package/dist/core/index.js +21 -0
  13. package/dist/core/interfaces/clock.interface.d.ts +18 -0
  14. package/dist/core/interfaces/clock.interface.js +1 -0
  15. package/dist/core/interfaces/index.d.ts +9 -0
  16. package/dist/core/interfaces/index.js +9 -0
  17. package/dist/core/interfaces/leased-worker-options.interface.d.ts +94 -0
  18. package/dist/core/interfaces/leased-worker-options.interface.js +1 -0
  19. package/dist/core/interfaces/limits.interface.d.ts +92 -0
  20. package/dist/core/interfaces/limits.interface.js +1 -0
  21. package/dist/core/interfaces/payload-codec.interface.d.ts +64 -0
  22. package/dist/core/interfaces/payload-codec.interface.js +1 -0
  23. package/dist/core/interfaces/result-waiter-options.interface.d.ts +52 -0
  24. package/dist/core/interfaces/result-waiter-options.interface.js +1 -0
  25. package/dist/core/interfaces/retry-settings.interface.d.ts +71 -0
  26. package/dist/core/interfaces/retry-settings.interface.js +1 -0
  27. package/dist/core/interfaces/schedule-store.interface.d.ts +143 -0
  28. package/dist/core/interfaces/schedule-store.interface.js +1 -0
  29. package/dist/core/interfaces/schedule.interface.d.ts +106 -0
  30. package/dist/core/interfaces/schedule.interface.js +1 -0
  31. package/dist/core/interfaces/scheduler-options.interface.d.ts +152 -0
  32. package/dist/core/interfaces/scheduler-options.interface.js +1 -0
  33. package/dist/core/limits/index.d.ts +1 -0
  34. package/dist/core/limits/index.js +1 -0
  35. package/dist/core/limits/limits.d.ts +61 -0
  36. package/dist/core/limits/limits.js +130 -0
  37. package/dist/core/results/index.d.ts +1 -0
  38. package/dist/core/results/index.js +1 -0
  39. package/dist/core/results/result-waiter.d.ts +54 -0
  40. package/dist/core/results/result-waiter.js +140 -0
  41. package/dist/core/retries/index.d.ts +1 -0
  42. package/dist/core/retries/index.js +1 -0
  43. package/dist/core/retries/retry.d.ts +60 -0
  44. package/dist/core/retries/retry.js +107 -0
  45. package/dist/core/scheduling/cron.util.d.ts +52 -0
  46. package/dist/core/scheduling/cron.util.js +262 -0
  47. package/dist/core/scheduling/encoded-schedule.store.d.ts +26 -0
  48. package/dist/core/scheduling/encoded-schedule.store.js +54 -0
  49. package/dist/core/scheduling/in-memory-schedule.store.d.ts +20 -0
  50. package/dist/core/scheduling/in-memory-schedule.store.js +90 -0
  51. package/dist/core/scheduling/index.d.ts +3 -0
  52. package/dist/core/scheduling/index.js +3 -0
  53. package/dist/core/scheduling/rrule.util.d.ts +55 -0
  54. package/dist/core/scheduling/rrule.util.js +371 -0
  55. package/dist/core/scheduling/schedule-spec.d.ts +57 -0
  56. package/dist/core/scheduling/schedule-spec.js +233 -0
  57. package/dist/core/scheduling/scheduler.d.ts +134 -0
  58. package/dist/core/scheduling/scheduler.js +450 -0
  59. package/dist/core/scheduling/time-zone.util.d.ts +18 -0
  60. package/dist/core/scheduling/time-zone.util.js +88 -0
  61. package/dist/core/time/clock.d.ts +28 -0
  62. package/dist/core/time/clock.js +35 -0
  63. package/dist/core/time/duration.d.ts +21 -0
  64. package/dist/core/time/duration.js +30 -0
  65. package/dist/core/time/index.d.ts +2 -0
  66. package/dist/core/time/index.js +2 -0
  67. package/dist/core/utils/canonical.util.d.ts +2 -0
  68. package/dist/core/utils/canonical.util.js +4 -0
  69. package/dist/core/utils/maybe.util.d.ts +8 -0
  70. package/dist/core/utils/maybe.util.js +14 -0
  71. package/dist/core/utils/payload-place.util.d.ts +10 -0
  72. package/dist/core/utils/payload-place.util.js +32 -0
  73. package/dist/core/workers/index.d.ts +1 -0
  74. package/dist/core/workers/index.js +1 -0
  75. package/dist/core/workers/leased-worker.d.ts +86 -0
  76. package/dist/core/workers/leased-worker.js +280 -0
  77. package/dist/cqrs/decorators/index.d.ts +2 -0
  78. package/dist/cqrs/decorators/index.js +2 -0
  79. package/dist/cqrs/decorators/signal-on.decorator.d.ts +18 -0
  80. package/dist/cqrs/decorators/signal-on.decorator.js +34 -0
  81. package/dist/cqrs/decorators/start-on.decorator.d.ts +18 -0
  82. package/dist/cqrs/decorators/start-on.decorator.js +49 -0
  83. package/dist/cqrs/index.d.ts +3 -0
  84. package/dist/cqrs/index.js +5 -0
  85. package/dist/cqrs/interfaces/index.d.ts +4 -0
  86. package/dist/cqrs/interfaces/index.js +4 -0
  87. package/dist/cqrs/interfaces/signal-on-options.interface.d.ts +22 -0
  88. package/dist/cqrs/interfaces/signal-on-options.interface.js +1 -0
  89. package/dist/cqrs/interfaces/start-on-options.interface.d.ts +23 -0
  90. package/dist/cqrs/interfaces/start-on-options.interface.js +1 -0
  91. package/dist/cqrs/interfaces/workflow-dispatcher-context.interface.d.ts +10 -0
  92. package/dist/cqrs/interfaces/workflow-dispatcher-context.interface.js +1 -0
  93. package/dist/cqrs/interfaces/workflow-event-route.interface.d.ts +20 -0
  94. package/dist/cqrs/interfaces/workflow-event-route.interface.js +1 -0
  95. package/dist/cqrs/utils/event-routes.util.d.ts +7 -0
  96. package/dist/cqrs/utils/event-routes.util.js +22 -0
  97. package/dist/cqrs/workflow-event.publisher.d.ts +52 -0
  98. package/dist/cqrs/workflow-event.publisher.js +257 -0
  99. package/dist/cqrs/workflow-events.explorer.d.ts +27 -0
  100. package/dist/cqrs/workflow-events.explorer.js +92 -0
  101. package/dist/cqrs/workflows-cqrs.module.d.ts +10 -0
  102. package/dist/cqrs/workflows-cqrs.module.js +27 -0
  103. package/dist/decorators/index.d.ts +1 -0
  104. package/dist/decorators/index.js +1 -0
  105. package/dist/decorators/workflow.decorator.d.ts +7 -0
  106. package/dist/decorators/workflow.decorator.js +52 -0
  107. package/dist/errors/child-workflow-failed.error.d.ts +13 -0
  108. package/dist/errors/child-workflow-failed.error.js +14 -0
  109. package/dist/errors/index.d.ts +13 -0
  110. package/dist/errors/index.js +13 -0
  111. package/dist/errors/non-retryable-step.error.d.ts +4 -0
  112. package/dist/errors/non-retryable-step.error.js +7 -0
  113. package/dist/errors/step-failed.error.d.ts +15 -0
  114. package/dist/errors/step-failed.error.js +16 -0
  115. package/dist/errors/step-timeout.error.d.ts +5 -0
  116. package/dist/errors/step-timeout.error.js +8 -0
  117. package/dist/errors/workflow-definition.error.d.ts +10 -0
  118. package/dist/errors/workflow-definition.error.js +13 -0
  119. package/dist/errors/workflow-failed.error.d.ts +20 -0
  120. package/dist/errors/workflow-failed.error.js +17 -0
  121. package/dist/errors/workflow-id-conflict.error.d.ts +6 -0
  122. package/dist/errors/workflow-id-conflict.error.js +9 -0
  123. package/dist/errors/workflow-interrupt.error.d.ts +32 -0
  124. package/dist/errors/workflow-interrupt.error.js +19 -0
  125. package/dist/errors/workflow-non-determinism.error.d.ts +4 -0
  126. package/dist/errors/workflow-non-determinism.error.js +7 -0
  127. package/dist/errors/workflow-not-found.error.d.ts +10 -0
  128. package/dist/errors/workflow-not-found.error.js +13 -0
  129. package/dist/errors/workflow-result-timeout.error.d.ts +11 -0
  130. package/dist/errors/workflow-result-timeout.error.js +13 -0
  131. package/dist/errors/workflow-state.error.d.ts +11 -0
  132. package/dist/errors/workflow-state.error.js +14 -0
  133. package/dist/errors/workflow.error.d.ts +7 -0
  134. package/dist/errors/workflow.error.js +7 -0
  135. package/dist/events/index.d.ts +3 -0
  136. package/dist/events/index.js +3 -0
  137. package/dist/events/workflow-events.interface.d.ts +145 -0
  138. package/dist/events/workflow-events.interface.js +1 -0
  139. package/dist/events/workflow-events.service.d.ts +17 -0
  140. package/dist/events/workflow-events.service.js +38 -0
  141. package/dist/events/workflows.channels.d.ts +3 -0
  142. package/dist/events/workflows.channels.js +9 -0
  143. package/dist/index.d.ts +20 -0
  144. package/dist/index.js +23 -0
  145. package/dist/interfaces/index.d.ts +13 -0
  146. package/dist/interfaces/index.js +13 -0
  147. package/dist/interfaces/serialized-workflow-error.interface.d.ts +6 -0
  148. package/dist/interfaces/serialized-workflow-error.interface.js +1 -0
  149. package/dist/interfaces/workflow-client.interface.d.ts +156 -0
  150. package/dist/interfaces/workflow-client.interface.js +1 -0
  151. package/dist/interfaces/workflow-clock.interface.d.ts +7 -0
  152. package/dist/interfaces/workflow-clock.interface.js +1 -0
  153. package/dist/interfaces/workflow-context.interface.d.ts +241 -0
  154. package/dist/interfaces/workflow-context.interface.js +1 -0
  155. package/dist/interfaces/workflow-decorator-options.interface.d.ts +101 -0
  156. package/dist/interfaces/workflow-decorator-options.interface.js +1 -0
  157. package/dist/interfaces/workflow-instance.interface.d.ts +83 -0
  158. package/dist/interfaces/workflow-instance.interface.js +1 -0
  159. package/dist/interfaces/workflow-payload-codec.interface.d.ts +32 -0
  160. package/dist/interfaces/workflow-payload-codec.interface.js +1 -0
  161. package/dist/interfaces/workflow-retry-options.interface.d.ts +9 -0
  162. package/dist/interfaces/workflow-retry-options.interface.js +1 -0
  163. package/dist/interfaces/workflow-runner.interface.d.ts +11 -0
  164. package/dist/interfaces/workflow-runner.interface.js +1 -0
  165. package/dist/interfaces/workflow-schedule.interface.d.ts +104 -0
  166. package/dist/interfaces/workflow-schedule.interface.js +1 -0
  167. package/dist/interfaces/workflow-storage.interface.d.ts +4 -0
  168. package/dist/interfaces/workflow-storage.interface.js +1 -0
  169. package/dist/interfaces/workflow-store.interface.d.ts +488 -0
  170. package/dist/interfaces/workflow-store.interface.js +1 -0
  171. package/dist/interfaces/workflows-module-options.interface.d.ts +80 -0
  172. package/dist/interfaces/workflows-module-options.interface.js +1 -0
  173. package/dist/mysql/index.d.ts +5 -0
  174. package/dist/mysql/index.js +8 -0
  175. package/dist/mysql/interfaces/index.d.ts +1 -0
  176. package/dist/mysql/interfaces/index.js +1 -0
  177. package/dist/mysql/interfaces/mysql-workflow-store-options.interface.d.ts +25 -0
  178. package/dist/mysql/interfaces/mysql-workflow-store-options.interface.js +1 -0
  179. package/dist/mysql/key-limits.d.ts +30 -0
  180. package/dist/mysql/key-limits.js +41 -0
  181. package/dist/mysql/migrations/index.d.ts +7 -0
  182. package/dist/mysql/migrations/index.js +16 -0
  183. package/dist/mysql/migrations/initial.migration.d.ts +9 -0
  184. package/dist/mysql/migrations/initial.migration.js +104 -0
  185. package/dist/mysql/mysql-workflow.store.d.ts +197 -0
  186. package/dist/mysql/mysql-workflow.store.js +828 -0
  187. package/dist/postgres/errors/index.d.ts +1 -0
  188. package/dist/postgres/errors/index.js +2 -0
  189. package/dist/postgres/index.d.ts +5 -0
  190. package/dist/postgres/index.js +8 -0
  191. package/dist/postgres/interfaces/index.d.ts +1 -0
  192. package/dist/postgres/interfaces/index.js +1 -0
  193. package/dist/postgres/interfaces/postgres-workflow-store-options.interface.d.ts +22 -0
  194. package/dist/postgres/interfaces/postgres-workflow-store-options.interface.js +1 -0
  195. package/dist/postgres/migrations/index.d.ts +6 -0
  196. package/dist/postgres/migrations/index.js +15 -0
  197. package/dist/postgres/migrations/initial.migration.d.ts +6 -0
  198. package/dist/postgres/migrations/initial.migration.js +106 -0
  199. package/dist/postgres/postgres-workflow.store.d.ts +132 -0
  200. package/dist/postgres/postgres-workflow.store.js +586 -0
  201. package/dist/services/index.d.ts +6 -0
  202. package/dist/services/index.js +6 -0
  203. package/dist/services/workflow-client.service.d.ts +146 -0
  204. package/dist/services/workflow-client.service.js +436 -0
  205. package/dist/services/workflow-execution.service.d.ts +267 -0
  206. package/dist/services/workflow-execution.service.js +1076 -0
  207. package/dist/services/workflow-registry.service.d.ts +56 -0
  208. package/dist/services/workflow-registry.service.js +172 -0
  209. package/dist/services/workflow-scheduler.service.d.ts +54 -0
  210. package/dist/services/workflow-scheduler.service.js +173 -0
  211. package/dist/services/workflow-schedules.service.d.ts +60 -0
  212. package/dist/services/workflow-schedules.service.js +193 -0
  213. package/dist/services/workflow-worker.service.d.ts +92 -0
  214. package/dist/services/workflow-worker.service.js +468 -0
  215. package/dist/signals/index.d.ts +1 -0
  216. package/dist/signals/index.js +1 -0
  217. package/dist/signals/workflow.signal.d.ts +17 -0
  218. package/dist/signals/workflow.signal.js +28 -0
  219. package/dist/sql/rate-windows.d.ts +41 -0
  220. package/dist/sql/rate-windows.js +53 -0
  221. package/dist/sql/workflow-rows.d.ts +16 -0
  222. package/dist/sql/workflow-rows.js +102 -0
  223. package/dist/sql/workflow-schema.error.d.ts +21 -0
  224. package/dist/sql/workflow-schema.error.js +15 -0
  225. package/dist/storage/encoded-workflow.store.d.ts +71 -0
  226. package/dist/storage/encoded-workflow.store.js +201 -0
  227. package/dist/storage/index.d.ts +1 -0
  228. package/dist/storage/index.js +1 -0
  229. package/dist/storage/workflow.storage.d.ts +55 -0
  230. package/dist/storage/workflow.storage.js +159 -0
  231. package/dist/stores/in-memory-workflow.store.d.ts +75 -0
  232. package/dist/stores/in-memory-workflow.store.js +380 -0
  233. package/dist/stores/index.d.ts +1 -0
  234. package/dist/stores/index.js +1 -0
  235. package/dist/testing/assertions.d.ts +10 -0
  236. package/dist/testing/assertions.js +72 -0
  237. package/dist/testing/index.d.ts +38 -0
  238. package/dist/testing/index.js +1089 -0
  239. package/dist/testing/schedule-store.contract.d.ts +33 -0
  240. package/dist/testing/schedule-store.contract.js +161 -0
  241. package/dist/utils/index.d.ts +7 -0
  242. package/dist/utils/index.js +7 -0
  243. package/dist/utils/journal-limits.util.d.ts +6 -0
  244. package/dist/utils/journal-limits.util.js +25 -0
  245. package/dist/utils/new-instance.util.d.ts +26 -0
  246. package/dist/utils/new-instance.util.js +87 -0
  247. package/dist/utils/normalize.util.d.ts +5 -0
  248. package/dist/utils/normalize.util.js +11 -0
  249. package/dist/utils/run-timeout.util.d.ts +3 -0
  250. package/dist/utils/run-timeout.util.js +15 -0
  251. package/dist/utils/schedule-spec.util.d.ts +16 -0
  252. package/dist/utils/schedule-spec.util.js +9 -0
  253. package/dist/utils/schedule-store.util.d.ts +15 -0
  254. package/dist/utils/schedule-store.util.js +44 -0
  255. package/dist/utils/serialize-error.util.d.ts +7 -0
  256. package/dist/utils/serialize-error.util.js +22 -0
  257. package/dist/utils/step-scope.util.d.ts +16 -0
  258. package/dist/utils/step-scope.util.js +38 -0
  259. package/dist/workflows.constants.d.ts +10 -0
  260. package/dist/workflows.constants.js +10 -0
  261. package/dist/workflows.module-definition.d.ts +4 -0
  262. package/dist/workflows.module-definition.js +6 -0
  263. package/dist/workflows.module.d.ts +24 -0
  264. package/dist/workflows.module.js +68 -0
  265. package/package.json +123 -0
@@ -0,0 +1,828 @@
1
+ import { Logger } from '@nestjs/common';
2
+ import { columns, ensureLockRows, keyColumn, lockKeys, mysqlErrorCode, quoteTable, retryOnDeadlock, SqlParams, toBool, toInt, toJson, toText, } from '@nestjs/store-kit/mysql';
3
+ import { RateWindowClaim } from '../sql/rate-windows.js';
4
+ import { CANCELLABLE_STATUSES, INSTANCE_COLUMNS, RUNNABLE_STATUSES, SCHEDULE_COLUMNS, SIGNAL_COLUMNS, toInstance, toSchedule } from '../sql/workflow-rows.js';
5
+ import { checkKey, MYSQL_KEY_LIMITS as L } from './key-limits.js';
6
+ import { mysqlWorkflowStoreSchema } from './migrations/index.js';
7
+ /** ER_DUP_ENTRY: MySQL rolled the statement back (not the transaction): the row exists. */
8
+ const DUPLICATE_KEY = 1062;
9
+ /**
10
+ * The lock (a row of the kit's `<schema>_locks`) that serializes signals (exclusive) with the writes that replace an
11
+ * instance's waits (shared), until the transaction ends: signal ids become visible in id order, and a signal can't
12
+ * slip between a suspension's check for missed signals and its commit.
13
+ */
14
+ const SIGNALS_LOCK = 'signals';
15
+ /** The lock a claim of `workflow` takes while it counts the slots of its concurrency limit. */
16
+ const concurrencyLock = (workflow) => `concurrency:${workflow}`;
17
+ const RUNNABLE = `(${RUNNABLE_STATUSES.map((status) => `'${status}'`).join(', ')})`;
18
+ const CANCELLABLE = `(${CANCELLABLE_STATUSES.map((status) => `'${status}'`).join(', ')})`;
19
+ /** How many rounds of candidates a claim tries at most, passing over those other claims hold (see `lockDue()`). */
20
+ const CLAIM_ROUNDS = 10;
21
+ /** The rows of one multi-row insert (Prisma prepares statements on the server: 65,535 placeholders at most). */
22
+ const ROWS_PER_INSERT = 1_000;
23
+ /**
24
+ * The first-party `WorkflowStore` on MySQL (8.4 LTS and 9.x), through the client the application already has
25
+ * (`fromMysql2()`, `fromDrizzle()`, `fromTypeOrm()`, `fromPrisma()`, `fromKysely()`). It keeps its tables in the
26
+ * connection's database, named `<schema>_<table>` (`nest_workflows_instances`...), which its migrations create and
27
+ * bring up to date, and it joins the application's transactions for `start()` and `signal()` with `{ transaction }`.
28
+ *
29
+ * ```ts
30
+ * @Module({
31
+ * imports: [WorkflowsModule.forRoot()],
32
+ * providers: [
33
+ * {
34
+ * provide: MySqlWorkflowStore,
35
+ * inject: [getDrizzleToken(), WorkflowStorage],
36
+ * useFactory: (db: Database, storage: WorkflowStorage) => new MySqlWorkflowStore({ executor: fromDrizzle(db) }, storage),
37
+ * },
38
+ * ],
39
+ * })
40
+ * export class AppModule {}
41
+ * ```
42
+ *
43
+ * Given `storage`, it registers itself (`storage.registerSource(this)`), as every store does. It checks the server and
44
+ * its schema, or migrates it (`migrate`), in `onModuleInit` (so before the worker starts), or at its first call
45
+ * outside Nest.
46
+ *
47
+ * On MySQL:
48
+ * - Ids, names and keys are case- and accent-sensitive, and bounded: an instance id, a workflow's or a signal's name,
49
+ * a signal's key or id, a concurrency or rate-limit key hold at most 255 characters, a schedule's id 230, a
50
+ * journal entry's name 512. A longer one fails with a `RangeError` that names it, before any SQL.
51
+ * - The application's transactions may be REPEATABLE READ, MySQL's default. `signal(..., { transaction })` finds the
52
+ * instances to wake with a locking read of their waits, which sees the latest committed ones whatever the level; the
53
+ * signal lock is held until the application's transaction ends, as on PostgreSQL. `start(..., { transaction })`
54
+ * reads the last signal id from the transaction's snapshot: under REPEATABLE READ, signals committed after the
55
+ * transaction's first read can match the new instance's waits.
56
+ * - The store's own transactions run READ COMMITTED and are run again when MySQL breaks a deadlock (1213). A deadlock in
57
+ * the application's transaction rolled the application's own writes back too: the store lets it through, for the
58
+ * application to retry its transaction.
59
+ */
60
+ export class MySqlWorkflowStore {
61
+ /**
62
+ * The SQL of the store's migrations, for teams that apply migrations with their own tool (drizzle-kit, TypeORM,
63
+ * Prisma Migrate, Flyway...) and run the store with `migrate: false`: from version `from` (default `0`, a new
64
+ * database) to `to` (default: the version this version of the package needs), with the bookkeeping that tells the
65
+ * store which versions a schema has (`<schema>_migrations`). MySQL commits each DDL statement on its own, so the
66
+ * statements don't run in one transaction: apply them in order, each once. Downgrades aren't supported. With
67
+ * `statementBreakpoints`, drizzle-kit's `--> statement-breakpoint` separates the statements, which its MySQL
68
+ * migrator needs (it runs one statement per call).
69
+ *
70
+ * ```ts
71
+ * // drizzle/0003_workflows.sql, created empty by `npx drizzle-kit generate --custom --name=workflows`
72
+ * writeFileSync('drizzle/0003_workflows.sql', MySqlWorkflowStore.migrationSql({ statementBreakpoints: true }));
73
+ * ```
74
+ */
75
+ static migrationSql(options = {}) {
76
+ return mysqlWorkflowStoreSchema.sql(options);
77
+ }
78
+ /**
79
+ * `migrationSql()`'s statements, one per string, for a migration tool that runs one statement per call (TypeORM's
80
+ * `queryRunner.query()`, mysql2): run them in order, each once.
81
+ *
82
+ * ```ts
83
+ * export class Workflows1790000000000 implements MigrationInterface {
84
+ * async up(queryRunner: QueryRunner): Promise<void> {
85
+ * for (const statement of MySqlWorkflowStore.migrationStatements()) {
86
+ * await queryRunner.query(statement);
87
+ * }
88
+ * }
89
+ * }
90
+ * ```
91
+ */
92
+ static migrationStatements(options = {}) {
93
+ return mysqlWorkflowStoreSchema.statements(options);
94
+ }
95
+ constructor(options, storage) {
96
+ this.logger = new Logger('WorkflowsModule');
97
+ /** The lock keys whose rows this store created, or found (see `createLockRows()`). */
98
+ this.lockRows = new Set();
99
+ const resolved = mysqlWorkflowStoreSchema.resolveOptions(options);
100
+ this.executor = resolved.executor;
101
+ this.schema = resolved.schema;
102
+ const t = (table) => quoteTable(this.schema, table, 'MySqlWorkflowStore');
103
+ this.t = {
104
+ instances: t('instances'),
105
+ journal: t('journal'),
106
+ waits: t('waits'),
107
+ signals: t('signals'),
108
+ schedules: t('schedules'),
109
+ rateLimits: t('rate_limits'),
110
+ };
111
+ this.readiness = mysqlWorkflowStoreSchema.readiness({ ...resolved, logger: this.logger });
112
+ storage?.registerSource(this);
113
+ }
114
+ /**
115
+ * Checks the server and migrates the schema (`migrate`) or checks it, before the worker starts: startup fails if it
116
+ * can't serve. Then creates the signal lock's row (see `createLockRows()`).
117
+ */
118
+ async onModuleInit() {
119
+ await this.ready();
120
+ }
121
+ /**
122
+ * Applies the migrations the schema hasn't had yet, whatever `migrate` says, one statement at a time under a lock
123
+ * (`GET_LOCK()`): of processes that migrate together, one applies them, and a run that stopped halfway resumes where
124
+ * it stopped. Resolves to the versions it applied (`[]`: none were pending).
125
+ */
126
+ migrate() {
127
+ return this.readiness.migrate();
128
+ }
129
+ // ---------------------------------------------------------------- instances
130
+ async create(instance) {
131
+ await this.ready();
132
+ checkInstance(instance);
133
+ return retryOnDeadlock(this.executor, (tx) => this.insertInstance(tx, instance));
134
+ }
135
+ /** `start(..., { transaction })`: the instance commits or rolls back with the application's rows. */
136
+ async createInTransaction(transaction, instance) {
137
+ const tx = this.executor.wrapTransaction(transaction);
138
+ await this.readiness.readyIn(tx);
139
+ checkInstance(instance);
140
+ return this.insertInstance(tx, instance);
141
+ }
142
+ async get(id, options = {}) {
143
+ await this.ready();
144
+ // One statement, one snapshot. JSON_ARRAYAGG() takes no ORDER BY: the positions and sequence numbers come along.
145
+ const journal = options.journal
146
+ ? `,
147
+ (SELECT CAST(JSON_ARRAYAGG(JSON_OBJECT('seq', j.seq, 'entry', j.entry)) AS CHAR) FROM ${this.t.journal} j WHERE j.instance_id = i.id) AS journal`
148
+ : '';
149
+ const p = new SqlParams();
150
+ const [row] = await this.executor.query(`SELECT ${columns(INSTANCE_COLUMNS, 'i')},
151
+ (SELECT CAST(JSON_ARRAYAGG(JSON_OBJECT('position', w.position, 'signal', w.\`signal\`, 'key', w.\`key\`)) AS CHAR) FROM ${this.t.waits} w WHERE w.instance_id = i.id) AS waits${journal}
152
+ FROM ${this.t.instances} i
153
+ WHERE i.id = ${p.text(id)}`, p.values);
154
+ if (!row) {
155
+ return null;
156
+ }
157
+ const waits = (toJson(row.waits) ?? [])
158
+ .sort((a, b) => a.position - b.position)
159
+ .map(({ signal, key }) => ({ signal, key }));
160
+ const details = { ...toInstance(row), waits };
161
+ if (!options.journal) {
162
+ return details;
163
+ }
164
+ const entries = (toJson(row.journal) ?? []).sort((a, b) => a.seq - b.seq);
165
+ return { ...details, journal: entries.map(({ entry }) => entry) };
166
+ }
167
+ async list(query) {
168
+ await this.ready();
169
+ const p = new SqlParams();
170
+ const where = [
171
+ ...(query.status ? [p.in('i.status', query.status)] : []),
172
+ ...(query.workflow !== undefined ? [`i.workflow = ${p.text(query.workflow)}`] : []),
173
+ ...(query.version !== undefined ? [`i.version = ${p.int(query.version)}`] : []),
174
+ ...(query.parentId !== undefined ? [`i.parent_id = ${p.text(query.parentId)}`] : []),
175
+ ...(query.scheduleId !== undefined ? [`i.schedule_id = ${p.text(query.scheduleId)}`] : []),
176
+ ];
177
+ const rows = await this.executor.query(`SELECT ${columns(INSTANCE_COLUMNS, 'i')} FROM ${this.t.instances} i${where.length > 0 ? ` WHERE ${where.join(' AND ')}` : ''}
178
+ ORDER BY i.created_at, i.id LIMIT ${p.limit(query.limit)} OFFSET ${p.limit(query.offset)}`, p.values);
179
+ return rows.map(toInstance);
180
+ }
181
+ async requestCancel(id, { reason, now, terminate }) {
182
+ await this.ready();
183
+ // A terminate also stops a compensating instance, and follows a cancel. One conditional update by primary key: of
184
+ // two concurrent requests, the second waits for the first's row and finds it changed.
185
+ const applies = terminate ? `status IN ${RUNNABLE} AND NOT terminate_requested` : `status IN ${CANCELLABLE} AND NOT cancel_requested`;
186
+ const p = new SqlParams();
187
+ const { affectedRows } = await this.executor.execute(`UPDATE ${this.t.instances}
188
+ SET cancel_requested = TRUE,${terminate ? ' terminate_requested = TRUE,' : ''} cancel_reason = ${p.text(reason)}, updated_at = ${p.bigint(now)},
189
+ wake_at = LEAST(COALESCE(wake_at, ${p.bigint(now)}), ${p.bigint(now)})
190
+ WHERE id = ${p.text(id)} AND ${applies}`, p.values);
191
+ return affectedRows === 1;
192
+ }
193
+ /** `WorkflowClient.retry()`: one conditional update, then the journal, in one transaction. */
194
+ async reopen(id, reopen) {
195
+ await this.ready();
196
+ checkEntries(reopen.entries);
197
+ return retryOnDeadlock(this.executor, async (tx) => {
198
+ // The update locks the row; of two concurrent retries, the second waits for it and finds it changed.
199
+ const p = new SqlParams();
200
+ const { affectedRows } = await tx.execute(`UPDATE ${this.t.instances}
201
+ SET status = ${p.text(reopen.status)}, error = ${p.json(reopen.error)}${reopen.deadline === undefined ? '' : `, deadline = ${p.bigint(reopen.deadline)}`}, wake_at = ${p.bigint(reopen.now)}, updated_at = ${p.bigint(reopen.now)}
202
+ WHERE id = ${p.text(id)} AND lease_token IS NULL AND status = ${p.text(reopen.expect.status)} AND runs = ${p.int(reopen.expect.runs)}`, p.values);
203
+ if (affectedRows === 0) {
204
+ return false;
205
+ }
206
+ if (reopen.entries.length > 0) {
207
+ await this.upsertEntries(tx, id, reopen.entries);
208
+ }
209
+ return true;
210
+ });
211
+ }
212
+ /** `WorkflowClient.delete()`: its journal and waits go with it, in the same transaction. */
213
+ async delete(id, statuses) {
214
+ await this.ready();
215
+ return retryOnDeadlock(this.executor, async (tx) => {
216
+ // Shared, before the row: a signal in the application's REPEATABLE READ transaction locks the waits it reads, then
217
+ // the instances it wakes, so this delete, which locks the instance, then its waits, waits for it first.
218
+ await lockKeys(tx, this.schema, SIGNALS_LOCK, { shared: true });
219
+ const p = new SqlParams();
220
+ const { affectedRows } = await tx.execute(`DELETE FROM ${this.t.instances} WHERE id = ${p.text(id)} AND ${p.in('status', statuses)}`, p.values);
221
+ if (affectedRows === 0) {
222
+ return false;
223
+ }
224
+ await this.deleteChildren(tx, [id]);
225
+ return true;
226
+ });
227
+ }
228
+ // ---------------------------------------------------------------- signals
229
+ async signal(signal) {
230
+ await this.ready();
231
+ checkSignal(signal);
232
+ return retryOnDeadlock(this.executor, (tx) => this.insertSignal(tx, signal));
233
+ }
234
+ /**
235
+ * `signal(..., { transaction })`: the signal and its wake-ups commit with the application's rows. Any isolation
236
+ * level: the wake-up reads the latest committed waits with a locking read, even under REPEATABLE READ.
237
+ */
238
+ async signalInTransaction(transaction, signal) {
239
+ const tx = this.executor.wrapTransaction(transaction);
240
+ await this.readiness.readyIn(tx);
241
+ checkSignal(signal);
242
+ return this.insertSignal(tx, signal);
243
+ }
244
+ async signals(query) {
245
+ await this.ready();
246
+ const p = new SqlParams();
247
+ const rows = await this.executor.query(`SELECT ${columns(SIGNAL_COLUMNS, 's')} FROM ${this.t.signals} s
248
+ WHERE s.name = ${p.text(query.name)} AND ${p.equals('s.`key`', query.key)} AND s.id > ${p.bigint(query.afterId)} AND s.id <= ${p.bigint(query.upToId)}
249
+ ORDER BY s.id`, p.values);
250
+ return rows.map((row) => ({ id: toInt(row.id), name: row.name, key: row.key, payload: toJson(row.payload), createdAt: toInt(row.created_at) }));
251
+ }
252
+ // ---------------------------------------------------------------- retention
253
+ async purge(query) {
254
+ await this.ready();
255
+ const finished = (p) => `${p.in('i.status', query.statuses)} AND i.updated_at < ${p.bigint(query.before)}`;
256
+ // Instances, oldest first: candidates by a consistent read, then those still finished locked (the condition again:
257
+ // an instance reopened since stays), skipping any another transaction holds. Their journals and waits go with them.
258
+ const instances = await retryOnDeadlock(this.executor, async (tx) => {
259
+ // As delete(): a signal in the application's transaction locks waits before instances.
260
+ await lockKeys(tx, this.schema, SIGNALS_LOCK, { shared: true });
261
+ const c = new SqlParams();
262
+ const candidates = await tx.query(`SELECT CAST(i.id AS CHAR) AS id FROM ${this.t.instances} i WHERE ${finished(c)} ORDER BY i.updated_at, i.id LIMIT ${c.limit(query.limit)}`, c.values);
263
+ if (candidates.length === 0) {
264
+ return 0;
265
+ }
266
+ const l = new SqlParams();
267
+ const locked = await tx.query(`SELECT CAST(i.id AS CHAR) AS id FROM ${this.t.instances} i FORCE INDEX (PRIMARY)
268
+ WHERE ${l.in('i.id', candidates.map((row) => row.id))} AND ${finished(l)} FOR UPDATE SKIP LOCKED`, l.values);
269
+ const ids = locked.map((row) => row.id);
270
+ if (ids.length === 0) {
271
+ return 0;
272
+ }
273
+ const d = new SqlParams();
274
+ await tx.execute(`DELETE FROM ${this.t.instances} WHERE ${d.in('id', ids)}`, d.values);
275
+ await this.deleteChildren(tx, ids);
276
+ return ids.length;
277
+ });
278
+ // Signals no instance can take: at or below every unfinished instance's cursor (new ones start at the newest
279
+ // signal), old enough, and never the newest, so the last signal id never goes back.
280
+ const signals = await retryOnDeadlock(this.executor, async (tx) => {
281
+ const [bounds] = await tx.query(`SELECT CAST(MAX(s.id) AS CHAR) AS newest,
282
+ CAST((SELECT MIN(i.signal_cursor) FROM ${this.t.instances} i WHERE i.status IN ${RUNNABLE}) AS CHAR) AS lowest
283
+ FROM ${this.t.signals} s`);
284
+ const newest = toInt(bounds?.newest);
285
+ if (newest === null) {
286
+ return 0;
287
+ }
288
+ const c = new SqlParams();
289
+ const candidates = await tx.query(`SELECT CAST(s.id AS CHAR) AS id FROM ${this.t.signals} s
290
+ WHERE s.created_at < ${c.bigint(query.before)} AND s.id <= ${c.bigint(Math.min(newest - 1, toInt(bounds.lowest) ?? newest))}
291
+ ORDER BY s.id LIMIT ${c.limit(query.limit)}`, c.values);
292
+ if (candidates.length === 0) {
293
+ return 0;
294
+ }
295
+ const d = new SqlParams();
296
+ return (await tx.execute(`DELETE FROM ${this.t.signals} WHERE ${d.in('id', candidates.map((row) => row.id))}`, d.values)).affectedRows;
297
+ });
298
+ // Rate-limit windows that ended, oldest first: candidates by a consistent read, then those still ended locked by key
299
+ // (a claim may have opened a new window in one meanwhile), skipping those a claim is locking: waiting for them, in
300
+ // another order than the claim's, could deadlock.
301
+ const rateLimits = await retryOnDeadlock(this.executor, async (tx) => {
302
+ const c = new SqlParams();
303
+ const candidates = await tx.query(`SELECT ${columns(['workflow', 'key'], 'rl')} FROM ${this.t.rateLimits} rl WHERE rl.window_end < ${c.bigint(query.before)}
304
+ ORDER BY rl.window_end, rl.workflow, rl.\`key\` LIMIT ${c.limit(query.limit)}`, c.values);
305
+ if (candidates.length === 0) {
306
+ return 0;
307
+ }
308
+ const l = new SqlParams();
309
+ const locked = await tx.query(`SELECT ${columns(['workflow', 'key'], 'rl')} FROM ${this.t.rateLimits} rl FORCE INDEX (PRIMARY)
310
+ WHERE (rl.workflow, rl.\`key\`) IN (${candidates.map((row) => `(${l.text(row.workflow)}, ${l.text(row.key)})`).join(', ')}) AND rl.window_end < ${l.bigint(query.before)}
311
+ FOR UPDATE SKIP LOCKED`, l.values);
312
+ if (locked.length === 0) {
313
+ return 0;
314
+ }
315
+ const d = new SqlParams();
316
+ return (await tx.execute(`DELETE FROM ${this.t.rateLimits} WHERE (workflow, \`key\`) IN (${locked.map((row) => `(${d.text(row.workflow)}, ${d.text(row.key)})`).join(', ')})`, d.values))
317
+ .affectedRows;
318
+ });
319
+ return { instances, signals, rateLimits };
320
+ }
321
+ // ---------------------------------------------------------------- schedules
322
+ async saveSchedule(save) {
323
+ await this.ready();
324
+ checkKey('a schedule id', save.id, L.scheduleId);
325
+ checkKey("a workflow's name", save.workflow, L.workflow);
326
+ return retryOnDeadlock(this.executor, async (tx) => {
327
+ const p = new SqlParams();
328
+ if (save.expectRevision === null) {
329
+ try {
330
+ await tx.execute(`INSERT INTO ${this.t.schedules} (id, workflow, declared, spec, input, paused, wake_at, state, revision, created_at, updated_at)
331
+ VALUES (${p.text(save.id)}, ${p.text(save.workflow)}, ${p.bool(save.declared)}, ${p.json(save.spec)}, ${p.json(save.input)}, ${p.bool(save.paused)},
332
+ ${p.bigint(save.wakeAt)}, ${p.json(save.state)}, 1, ${p.bigint(save.now)}, ${p.bigint(save.now)})`, p.values);
333
+ }
334
+ catch (error) {
335
+ if (mysqlErrorCode(error) === DUPLICATE_KEY) {
336
+ return null;
337
+ }
338
+ throw error;
339
+ }
340
+ }
341
+ else {
342
+ // One conditional update: of two saves that read the same revision, the second waits for the row and finds it
343
+ // changed.
344
+ const { affectedRows } = await tx.execute(`UPDATE ${this.t.schedules}
345
+ SET workflow = ${p.text(save.workflow)}, declared = ${p.bool(save.declared)}, spec = ${p.json(save.spec)}, input = ${p.json(save.input)}, paused = ${p.bool(save.paused)},
346
+ wake_at = ${p.bigint(save.wakeAt)}, state = ${p.json(save.state)}, updated_at = ${p.bigint(save.now)}, revision = revision + 1${save.releaseLease ? ', lease_token = NULL, lease_until = NULL' : ''}
347
+ WHERE id = ${p.text(save.id)} AND revision = ${p.int(save.expectRevision)}`, p.values);
348
+ if (affectedRows === 0) {
349
+ return null;
350
+ }
351
+ }
352
+ // As stored: the row this transaction wrote, and still locks.
353
+ return this.readSchedule(tx, save.id);
354
+ });
355
+ }
356
+ async getSchedule(id) {
357
+ await this.ready();
358
+ return this.readSchedule(this.executor, id);
359
+ }
360
+ async listSchedules(query) {
361
+ await this.ready();
362
+ const p = new SqlParams();
363
+ const where = [
364
+ ...(query.workflow !== undefined ? [`s.workflow = ${p.text(query.workflow)}`] : []),
365
+ ...(query.declared !== undefined ? [`s.declared = ${p.bool(query.declared)}`] : []),
366
+ ];
367
+ const rows = await this.executor.query(`SELECT ${columns(SCHEDULE_COLUMNS, 's')} FROM ${this.t.schedules} s${where.length > 0 ? ` WHERE ${where.join(' AND ')}` : ''}
368
+ ORDER BY s.id LIMIT ${p.limit(query.limit)} OFFSET ${p.limit(query.offset)}`, p.values);
369
+ return rows.map(toSchedule);
370
+ }
371
+ async deleteSchedule(id, revision) {
372
+ await this.ready();
373
+ const p = new SqlParams();
374
+ const { affectedRows } = await this.executor.execute(`DELETE FROM ${this.t.schedules} WHERE id = ${p.text(id)}${revision !== undefined ? ` AND revision = ${p.int(revision)}` : ''}`, p.values);
375
+ return affectedRows === 1;
376
+ }
377
+ async claimSchedules(request) {
378
+ await this.ready();
379
+ checkKey('a lease token', request.token, L.leaseToken);
380
+ return retryOnDeadlock(this.executor, async (tx) => {
381
+ const due = (p) => `NOT s.paused AND s.wake_at IS NOT NULL AND s.wake_at <= ${p.bigint(request.now)} AND (s.lease_until IS NULL OR s.lease_until < ${p.bigint(request.now)}) AND ${p.in('s.workflow', request.workflows)}`;
382
+ const ids = await this.lockDue(tx, this.t.schedules, 's', due, 's.wake_at, s.id', request.limit);
383
+ if (ids.length === 0) {
384
+ return [];
385
+ }
386
+ const p = new SqlParams();
387
+ await tx.execute(`UPDATE ${this.t.schedules} FORCE INDEX (PRIMARY) SET lease_token = ${p.text(request.token)}, lease_owner = ${p.text(request.owner)}, lease_until = ${p.bigint(request.leaseUntil)}
388
+ WHERE ${p.in('id', ids)}`, p.values);
389
+ const r = new SqlParams();
390
+ const rows = await tx.query(`SELECT ${columns(SCHEDULE_COLUMNS, 's')} FROM ${this.t.schedules} s WHERE ${r.in('s.id', ids)} ORDER BY s.wake_at, s.id`, r.values);
391
+ return rows.map(toSchedule);
392
+ });
393
+ }
394
+ async writeSchedule(id, token, write) {
395
+ await this.ready();
396
+ const p = new SqlParams();
397
+ const { affectedRows } = await this.executor.execute(`UPDATE ${this.t.schedules}
398
+ SET state = ${p.json(write.state)}, wake_at = ${p.bigint(write.wakeAt)}, revision = revision + 1, updated_at = ${p.bigint(write.now)}${write.release ? ', lease_token = NULL, lease_until = NULL' : ''}
399
+ WHERE id = ${p.text(id)} AND lease_token = ${p.text(token)}`, p.values);
400
+ return affectedRows === 1;
401
+ }
402
+ // ---------------------------------------------------------------- the worker
403
+ async claim(request) {
404
+ await this.ready();
405
+ checkKey('a lease token', request.token, L.leaseToken);
406
+ await this.createLockRows((request.limits ?? []).map((limit) => concurrencyLock(limit.workflow)));
407
+ return retryOnDeadlock(this.executor, async (tx) => {
408
+ if (request.limits?.length || request.rateLimits?.length) {
409
+ return this.claimWithin(tx, request);
410
+ }
411
+ const ids = await this.lockDue(tx, this.t.instances, 'i', (p) => this.isDue(p, request, 'i'), 'i.priority, i.wake_at, i.created_at, i.id', request.limit);
412
+ // The last signal id, read after the claim: the executions' signal cursor.
413
+ return { instances: ids.length > 0 ? await this.lease(tx, request, ids) : [], lastSignalId: await this.lastSignalId(tx) };
414
+ });
415
+ }
416
+ /**
417
+ * A claim under concurrency or rate limits. Claims of a workflow with a concurrency limit take its lock first (the
418
+ * kit's `lockKeys()`, in one order), so they count the slots live leases hold and lease the instances that fit one
419
+ * after the other: two never both take the last slot. Rate-limit windows are counted again under their rows' locks,
420
+ * once the instances are picked (see `takeRoom()`).
421
+ */
422
+ async claimWithin(tx, request) {
423
+ const limits = request.limits ?? [];
424
+ const rates = request.rateLimits ?? [];
425
+ await lockKeys(tx, this.schema, limits.map((limit) => concurrencyLock(limit.workflow)));
426
+ // Candidates with no room at all are passed over; then each concurrency key's first, as many as its free slots; of
427
+ // those, each rate key's, as many as its window has room for; of those, each workflow's, as many as both its free
428
+ // slots and its window allow. A full key is passed over, not waited behind. The windows read here are a snapshot,
429
+ // counted again under their locks in takeRoom().
430
+ const limitRows = limits.map((l) => ({ workflow: l.workflow, total: l.limit, per_key: l.perKey }));
431
+ const rateRows = rates.map((r) => ({ workflow: r.workflow, total: r.limit?.max ?? null, per_key: r.perKey?.max ?? null }));
432
+ const rules = (json) => `JSON_TABLE(${json}, '$[*]' COLUMNS (workflow ${keyColumn(L.workflow)} PATH '$.workflow', total int PATH '$.total', per_key int PATH '$.per_key'))`;
433
+ const p = new SqlParams();
434
+ const rows = await tx.query(`WITH limits AS (
435
+ SELECT * FROM ${rules(p.json(limitRows))} AS l
436
+ ),
437
+ rates AS (
438
+ SELECT * FROM ${rules(p.json(rateRows))} AS r
439
+ ),
440
+ held AS (
441
+ SELECT i.workflow AS workflow, i.concurrency_key AS ckey, COUNT(*) AS n
442
+ FROM ${this.t.instances} i
443
+ WHERE i.lease_until >= ${p.bigint(request.now)} AND i.workflow IN (SELECT workflow FROM limits)
444
+ GROUP BY i.workflow, i.concurrency_key
445
+ ),
446
+ used AS (
447
+ SELECT rl.workflow AS workflow, rl.\`key\` AS rkey, rl.\`count\` AS n
448
+ FROM ${this.t.rateLimits} rl
449
+ WHERE rl.window_end > ${p.bigint(request.now)} AND rl.workflow IN (SELECT workflow FROM rates)
450
+ ),
451
+ due AS (
452
+ SELECT i.id AS id, i.workflow AS workflow, i.concurrency_key AS ckey, i.rate_limit_key AS rkey, i.priority AS priority,
453
+ i.wake_at AS wake_at, i.created_at AS created_at,
454
+ ROW_NUMBER() OVER (PARTITION BY i.workflow, i.concurrency_key ORDER BY i.priority, i.wake_at, i.created_at, i.id) AS key_rank
455
+ FROM ${this.t.instances} i
456
+ LEFT JOIN limits l ON l.workflow = i.workflow
457
+ LEFT JOIN rates r ON r.workflow = i.workflow
458
+ WHERE ${this.isDue(p, request, 'i')}
459
+ AND (l.total IS NULL OR COALESCE((SELECT SUM(h.n) FROM held h WHERE h.workflow = l.workflow), 0) < l.total)
460
+ AND (l.per_key IS NULL OR i.concurrency_key IS NULL
461
+ OR COALESCE((SELECT h.n FROM held h WHERE h.workflow = l.workflow AND h.ckey = i.concurrency_key), 0) < l.per_key)
462
+ AND (r.total IS NULL OR COALESCE((SELECT u.n FROM used u WHERE u.workflow = r.workflow AND u.rkey = ''), 0) < r.total)
463
+ AND (r.per_key IS NULL OR i.rate_limit_key IS NULL
464
+ OR COALESCE((SELECT u.n FROM used u WHERE u.workflow = r.workflow AND u.rkey = i.rate_limit_key), 0) < r.per_key)
465
+ ),
466
+ fits_key AS (
467
+ SELECT d.*, ROW_NUMBER() OVER (PARTITION BY d.workflow, d.rkey ORDER BY d.priority, d.wake_at, d.created_at, d.id) AS rate_key_rank
468
+ FROM due d
469
+ LEFT JOIN limits l ON l.workflow = d.workflow
470
+ WHERE l.per_key IS NULL OR d.ckey IS NULL
471
+ OR d.key_rank <= l.per_key - COALESCE((SELECT h.n FROM held h WHERE h.workflow = d.workflow AND h.ckey = d.ckey), 0)
472
+ ),
473
+ fits_rate_key AS (
474
+ SELECT f.*, ROW_NUMBER() OVER (PARTITION BY f.workflow ORDER BY f.priority, f.wake_at, f.created_at, f.id) AS workflow_rank
475
+ FROM fits_key f
476
+ LEFT JOIN rates r ON r.workflow = f.workflow
477
+ WHERE r.per_key IS NULL OR f.rkey IS NULL
478
+ OR f.rate_key_rank <= r.per_key - COALESCE((SELECT u.n FROM used u WHERE u.workflow = f.workflow AND u.rkey = f.rkey), 0)
479
+ )
480
+ SELECT CAST(f.id AS CHAR) AS id FROM fits_rate_key f
481
+ LEFT JOIN limits l ON l.workflow = f.workflow
482
+ LEFT JOIN rates r ON r.workflow = f.workflow
483
+ WHERE (l.total IS NULL OR f.workflow_rank <= l.total - COALESCE((SELECT SUM(h.n) FROM held h WHERE h.workflow = f.workflow), 0))
484
+ AND (r.total IS NULL OR f.workflow_rank <= r.total - COALESCE((SELECT u.n FROM used u WHERE u.workflow = f.workflow AND u.rkey = ''), 0))
485
+ ORDER BY f.priority, f.wake_at, f.created_at, f.id
486
+ LIMIT ${p.limit(request.limit)}`, p.values);
487
+ if (rows.length === 0) {
488
+ return { instances: [], lastSignalId: await this.lastSignalId(tx) };
489
+ }
490
+ const lock = new SqlParams();
491
+ const picked = await tx.query(`SELECT ${columns(['id', 'workflow', 'rate_limit_key'], 'i')} FROM ${this.t.instances} i FORCE INDEX (PRIMARY)
492
+ WHERE ${lock.in('i.id', rows.map((row) => row.id))} AND ${this.isDue(lock, request, 'i')}
493
+ ORDER BY i.priority, i.wake_at, i.created_at, i.id
494
+ FOR UPDATE SKIP LOCKED`, lock.values);
495
+ const granted = await this.takeRoom(tx, request, picked.map((row) => ({ id: row.id, workflow: row.workflow, rateLimitKey: row.rate_limit_key })));
496
+ return { instances: granted.length > 0 ? await this.lease(tx, request, granted) : [], lastSignalId: await this.lastSignalId(tx) };
497
+ }
498
+ /**
499
+ * Of `picked` (in claim order), the instances their rate-limit windows have room for, recorded in the windows. Each
500
+ * window's row is inserted where missing and locked, in one order (`RateWindowClaim.windows`): the count read under
501
+ * the lock is exact (a concurrent claim of the same window waits for this one), and a purge can't delete the row in
502
+ * between.
503
+ */
504
+ async takeRoom(tx, request, picked) {
505
+ const claim = new RateWindowClaim(request, picked);
506
+ if (claim.windows.length === 0) {
507
+ return picked.map((instance) => instance.id);
508
+ }
509
+ // An existing row is locked exclusively by the update that leaves it as it is. (Its count of affected rows says
510
+ // nothing: an insert and an unchanged duplicate count alike.)
511
+ const p = new SqlParams();
512
+ await tx.execute(`INSERT INTO ${this.t.rateLimits} (workflow, \`key\`, window_end, \`count\`)
513
+ VALUES ${claim.windows.map(({ workflow, key }) => `(${p.text(workflow)}, ${p.text(key)}, 0, 0)`).join(', ')}
514
+ ON DUPLICATE KEY UPDATE \`count\` = \`count\``, p.values);
515
+ // The rows this transaction just locked: a plain read sees their latest values (nobody else can change them now).
516
+ const r = new SqlParams();
517
+ const locked = await tx.query(`SELECT ${columns(['workflow', 'key', 'window_end', 'count'], 'rl')} FROM ${this.t.rateLimits} rl
518
+ WHERE (rl.workflow, rl.\`key\`) IN (${claim.windows.map(({ workflow, key }) => `(${r.text(workflow)}, ${r.text(key)})`).join(', ')})`, r.values);
519
+ const { granted, changed } = claim.grant(locked.map((row) => ({ workflow: row.workflow, key: row.key, windowEnd: toInt(row.window_end), count: toInt(row.count) })));
520
+ if (changed.length > 0) {
521
+ const u = new SqlParams();
522
+ const values = changed.map((w) => ({ workflow: w.workflow, key: w.key, window_end: w.windowEnd, count: w.count }));
523
+ await tx.execute(`UPDATE ${this.t.rateLimits} rl
524
+ JOIN JSON_TABLE(${u.json(values)}, '$[*]' COLUMNS (
525
+ workflow ${keyColumn(L.workflow)} PATH '$.workflow',
526
+ \`key\` ${keyColumn(L.rateLimitKey)} PATH '$.key',
527
+ window_end bigint PATH '$.window_end',
528
+ \`count\` int PATH '$.count'
529
+ )) AS v ON rl.workflow = v.workflow AND rl.\`key\` = v.\`key\`
530
+ SET rl.window_end = v.window_end, rl.\`count\` = v.\`count\``, u.values);
531
+ }
532
+ return granted;
533
+ }
534
+ /**
535
+ * Locks up to `limit` rows of `table` (as `alias`) that `due` selects, in `order`, passing over the rows other
536
+ * transactions hold, as PostgreSQL's `FOR UPDATE SKIP LOCKED` under `ORDER BY ... LIMIT` does. InnoDB's own locks
537
+ * every row its sort reads, not only those it returns (a claim of 7 among 60 due instances held all 60, and a
538
+ * concurrent claim found none), so each round:
539
+ *
540
+ * 1. a consistent read finds the next candidates in `order` (it locks nothing, whatever the plan): as many as the rows
541
+ * still wanted, and as many more as other transactions held in the rounds before, so a round reaches past the rows
542
+ * the claims beside this one are taking;
543
+ * 2. a locking read takes those still due that no one holds (`SKIP LOCKED`), by primary key and in its order, up to the
544
+ * rows wanted: InnoDB stops there, and locks no candidate it doesn't return. (Among the candidates of a round, the
545
+ * ones taken are the first free by id: under contention, a claim may take a less overdue one than another claim.)
546
+ *
547
+ * Resolves to the ids locked.
548
+ */
549
+ async lockDue(tx, table, alias, due, order, limit) {
550
+ const locked = [];
551
+ const tried = [];
552
+ let held = 0;
553
+ for (let round = 0; round < CLAIM_ROUNDS && locked.length < limit; round++) {
554
+ const wanted = limit - locked.length;
555
+ const batch = wanted + held;
556
+ const p = new SqlParams();
557
+ const candidates = await tx.query(`SELECT CAST(${alias}.id AS CHAR) AS id FROM ${table} ${alias}
558
+ WHERE ${due(p)}${tried.length > 0 ? ` AND NOT (${p.in(`${alias}.id`, tried)})` : ''}
559
+ ORDER BY ${order} LIMIT ${p.limit(batch)}`, p.values);
560
+ if (candidates.length === 0) {
561
+ break;
562
+ }
563
+ const ids = candidates.map((row) => row.id);
564
+ tried.push(...ids);
565
+ const q = new SqlParams();
566
+ const rows = await tx.query(`SELECT CAST(${alias}.id AS CHAR) AS id FROM ${table} ${alias} FORCE INDEX (PRIMARY)
567
+ WHERE ${q.in(`${alias}.id`, ids)} AND ${due(q)} ORDER BY ${alias}.id LIMIT ${q.limit(wanted)} FOR UPDATE SKIP LOCKED`, q.values);
568
+ locked.push(...rows.map((row) => row.id));
569
+ held += candidates.length - rows.length;
570
+ if (candidates.length < batch) {
571
+ break;
572
+ }
573
+ }
574
+ return locked;
575
+ }
576
+ /** Leases the instances `ids` (locked by this transaction), and reads them back in claim order. */
577
+ async lease(tx, request, ids) {
578
+ const p = new SqlParams();
579
+ await tx.execute(`UPDATE ${this.t.instances} FORCE INDEX (PRIMARY)
580
+ SET lease_token = ${p.text(request.token)}, lease_owner = ${p.text(request.owner)}, lease_until = ${p.bigint(request.leaseUntil)},
581
+ runs = runs + 1, updated_at = ${p.bigint(request.now)}, status = CASE WHEN status = 'compensating' THEN status ELSE 'running' END
582
+ WHERE ${p.in('id', ids)}`, p.values);
583
+ const r = new SqlParams();
584
+ const rows = await tx.query(`SELECT ${columns(INSTANCE_COLUMNS, 'i')} FROM ${this.t.instances} i WHERE ${r.in('i.id', ids)} ORDER BY i.priority, i.wake_at, i.created_at, i.id`, r.values);
585
+ return rows.map(toInstance);
586
+ }
587
+ async renew(id, token, leaseUntil) {
588
+ await this.ready();
589
+ // The fence: the rows the update matched (it matches the lease holder's row even when it writes the value the row
590
+ // has, a manual clock's extension). The flags, read right after, are the instance's latest.
591
+ const p = new SqlParams();
592
+ const { affectedRows } = await this.executor.execute(`UPDATE ${this.t.instances} SET lease_until = ${p.bigint(leaseUntil)} WHERE id = ${p.text(id)} AND lease_token = ${p.text(token)}`, p.values);
593
+ if (affectedRows === 0) {
594
+ return null;
595
+ }
596
+ const r = new SqlParams();
597
+ const [row] = await this.executor.query(`SELECT ${columns(['cancel_requested', 'terminate_requested'])} FROM ${this.t.instances} WHERE id = ${r.text(id)}`, r.values);
598
+ return row ? { cancelRequested: toBool(row.cancel_requested), terminateRequested: toBool(row.terminate_requested) } : null;
599
+ }
600
+ async write(id, token, write) {
601
+ await this.ready();
602
+ checkEntries(write.entries);
603
+ if (write.signal) {
604
+ checkSignal(write.signal);
605
+ }
606
+ for (const wait of write.release?.waits ?? []) {
607
+ checkKey("a wait's signal name", wait.signal, L.signal);
608
+ checkKey("a wait's signal key", wait.key, L.signalKey);
609
+ }
610
+ return retryOnDeadlock(this.executor, async (tx) => {
611
+ const release = write.release;
612
+ if (write.signal) {
613
+ await lockKeys(tx, this.schema, SIGNALS_LOCK);
614
+ }
615
+ else if (release) {
616
+ // Shared, before the row: a suspension's check for missed signals never interleaves with a signal. And any
617
+ // release replaces the instance's waits, which a signal in the application's REPEATABLE READ transaction locks
618
+ // before the instances it wakes: waiting for it here, this write never takes the two in the other order.
619
+ await lockKeys(tx, this.schema, SIGNALS_LOCK, { shared: true });
620
+ }
621
+ // The fence: only the lease holder writes, and the row stays locked until commit.
622
+ const fence = new SqlParams();
623
+ const [fenced] = await tx.query(`SELECT ${columns(['cancel_requested'])} FROM ${this.t.instances} WHERE id = ${fence.text(id)} AND lease_token = ${fence.text(token)} FOR UPDATE`, fence.values);
624
+ if (!fenced) {
625
+ return false;
626
+ }
627
+ if (write.entries.length > 0) {
628
+ await this.upsertEntries(tx, id, write.entries);
629
+ }
630
+ if (write.signal) {
631
+ await this.insertSignal(tx, write.signal);
632
+ }
633
+ // Fields the write leaves out (undefined) keep their value.
634
+ let wakeAt;
635
+ if (release) {
636
+ const missed = await this.replaceWaits(tx, id, release);
637
+ wakeAt = missed || (toBool(fenced.cancel_requested) && write.status === 'suspended') ? write.now : release.wakeAt;
638
+ }
639
+ const p = new SqlParams();
640
+ const set = [
641
+ ...(write.status !== undefined ? [`status = ${p.text(write.status)}`] : []),
642
+ ...(write.output !== undefined ? [`output = ${p.json(write.output)}`] : []),
643
+ ...(write.error !== undefined ? [`error = ${p.json(write.error)}`] : []),
644
+ ...(write.customStatus !== undefined ? [`custom_status = ${p.json(write.customStatus)}`] : []),
645
+ ...(release ? ['lease_token = NULL', 'lease_until = NULL', `wake_at = ${p.bigint(wakeAt ?? null)}`] : []),
646
+ ];
647
+ if (set.length > 0) {
648
+ await tx.execute(`UPDATE ${this.t.instances} SET ${set.join(', ')}, updated_at = ${p.bigint(write.now)} WHERE id = ${p.text(id)}`, p.values);
649
+ }
650
+ return true;
651
+ });
652
+ }
653
+ // ---------------------------------------------------------------- internals
654
+ /** The store's readiness (the server and the schema), then the signal lock's row: before its own first statement. */
655
+ async ready() {
656
+ await this.readiness.ready();
657
+ await this.createLockRows([SIGNALS_LOCK]);
658
+ }
659
+ /**
660
+ * Creates the rows of lock keys (the kit's `<schema>_locks`, one per key, never deleted) ahead of time, with the kit's
661
+ * `ensureLockRows()`: in transactions of their own, which commit before any transaction takes those locks. When the
662
+ * transaction that created a lock's row rolls back, MySQL makes the transactions waiting for that row deadlock
663
+ * (1213): so a lock's first use, which may be in the application's transaction (`signal(..., { transaction })`) or in
664
+ * one that fails, never creates its row. The rows there already are only noted, by a read that takes no lock: a
665
+ * process's startup never waits behind the transactions holding them (a signal in the application's transaction).
666
+ * The store's lock keys are few: `signals`, and a concurrency lock per workflow with a limit. (A store first called
667
+ * inside the application's transaction, before `onModuleInit()` or any call outside one, creates the signal lock's row
668
+ * there.)
669
+ */
670
+ async createLockRows(keys) {
671
+ const missing = keys.filter((key) => !this.lockRows.has(key));
672
+ if (missing.length === 0) {
673
+ return;
674
+ }
675
+ await ensureLockRows(this.executor, this.schema, missing);
676
+ for (const key of missing) {
677
+ this.lockRows.add(key);
678
+ }
679
+ }
680
+ async insertInstance(tx, i) {
681
+ // An instance deleted between the insert that met it and the read of it is created again.
682
+ for (let attempt = 1;; attempt++) {
683
+ // The last signal id, read on its own: as an INSERT's subquery, REPEATABLE READ would lock the signals' last row
684
+ // and the gap after it until the transaction ends, and hold every signal up behind the application's transaction.
685
+ const cursor = await this.lastSignalId(tx);
686
+ const p = new SqlParams();
687
+ let created = true;
688
+ try {
689
+ await tx.execute(`INSERT INTO ${this.t.instances} (id, workflow, version, parent_id, parent_close, concurrency_key, rate_limit_key, priority, schedule_id,
690
+ scheduled_at, status, input, deadline, wake_at, signal_cursor, created_at, updated_at)
691
+ VALUES (${p.text(i.id)}, ${p.text(i.workflow)}, ${p.int(i.version)}, ${p.text(i.parentId ?? null)}, ${p.text(i.parentClose ?? null)},
692
+ ${p.text(i.concurrencyKey ?? null)}, ${p.text(i.rateLimitKey ?? null)}, ${p.int(i.priority ?? 0)}, ${p.text(i.scheduleId ?? null)},
693
+ ${p.bigint(i.scheduledAt ?? null)}, 'pending', ${p.json(i.input)}, ${p.bigint(i.deadline)}, ${p.bigint(i.now)},
694
+ ${p.bigint(cursor)}, ${p.bigint(i.now)}, ${p.bigint(i.now)})`, p.values);
695
+ }
696
+ catch (error) {
697
+ // MySQL rolled the statement back, not the transaction: the application's goes on.
698
+ if (mysqlErrorCode(error) !== DUPLICATE_KEY) {
699
+ throw error;
700
+ }
701
+ created = false;
702
+ }
703
+ // After a duplicate, a locking read: a plain one would read a REPEATABLE READ transaction's snapshot, which may
704
+ // predate the row. (A row this transaction wrote is its own either way.)
705
+ const read = new SqlParams();
706
+ const [row] = await tx.query(`SELECT ${columns(INSTANCE_COLUMNS, 'i')} FROM ${this.t.instances} i WHERE i.id = ${read.text(i.id)}${created ? '' : ' FOR SHARE'}`, read.values);
707
+ if (row) {
708
+ return { instance: toInstance(row), created };
709
+ }
710
+ if (attempt === 3) {
711
+ throw new Error(`MySqlWorkflowStore: instance "${i.id}" was deleted each time its creation met it.`);
712
+ }
713
+ }
714
+ }
715
+ async insertSignal(tx, s) {
716
+ await lockKeys(tx, this.schema, SIGNALS_LOCK);
717
+ // A name and dedupe id stored before make this a no-op: the unique key decides. MySQL rolls back only the statement
718
+ // that met it, so this works in the application's transaction too; the earlier signal is read with a locking read,
719
+ // which sees it whatever the transaction's snapshot.
720
+ const p = new SqlParams();
721
+ try {
722
+ await tx.execute(`INSERT INTO ${this.t.signals} (name, \`key\`, dedupe_id, payload, created_at)
723
+ VALUES (${p.text(s.name)}, ${p.text(s.key)}, ${p.text(s.dedupeId)}, ${p.json(s.payload)}, ${p.bigint(s.now)})`, p.values);
724
+ }
725
+ catch (error) {
726
+ if (mysqlErrorCode(error) !== DUPLICATE_KEY) {
727
+ throw error;
728
+ }
729
+ const read = new SqlParams();
730
+ const [earlier] = await tx.query(`SELECT ${columns(['id', 'key'], 's')} FROM ${this.t.signals} s WHERE s.name = ${read.text(s.name)} AND s.dedupe_id = ${read.text(s.dedupeId)} FOR SHARE`, read.values);
731
+ return { id: toInt(earlier.id), woken: 0, created: false, key: toText(earlier.key) };
732
+ }
733
+ // The id the insert took: LAST_INSERT_ID() is the connection's, and this transaction's statements share one.
734
+ const [inserted] = await tx.query('SELECT CAST(LAST_INSERT_ID() AS CHAR) AS id');
735
+ // Every suspended instance waiting for it, not already due. The waits are read with a locking read, which sees the
736
+ // latest committed ones whatever the transaction's level (under the signal lock, no suspension can commit others
737
+ // meanwhile); the instances are then updated by primary key, so a REPEATABLE READ transaction locks no more rows
738
+ // than those, whatever the plan. The rows the update matched are the instances woken.
739
+ const w = new SqlParams();
740
+ const waiting = await tx.query(`SELECT CAST(wt.instance_id AS CHAR) AS id FROM ${this.t.waits} wt FORCE INDEX (waits_signal)
741
+ WHERE wt.\`signal\` = ${w.text(s.name)} AND ${w.equals('wt.`key`', s.key)} FOR SHARE`, w.values);
742
+ const ids = [...new Set(waiting.map((row) => row.id))];
743
+ let woken = 0;
744
+ for (let start = 0; start < ids.length; start += ROWS_PER_INSERT) {
745
+ const u = new SqlParams();
746
+ const { affectedRows } = await tx.execute(`UPDATE ${this.t.instances} FORCE INDEX (PRIMARY) SET wake_at = ${u.bigint(s.now)}, updated_at = ${u.bigint(s.now)}
747
+ WHERE ${u.in('id', ids.slice(start, start + ROWS_PER_INSERT))} AND status = 'suspended' AND (wake_at IS NULL OR wake_at > ${u.bigint(s.now)})`, u.values);
748
+ woken += affectedRows;
749
+ }
750
+ return { id: toInt(inserted.id), woken, created: true, key: s.key };
751
+ }
752
+ /**
753
+ * Replaces the instance's waits, and says whether a signal committed after the execution read its cursor matches
754
+ * one of the new ones: then the instance stays due instead of losing it. (Run under the signal lock, shared: every
755
+ * signal before it has committed, and none after it can commit before this transaction.)
756
+ */
757
+ async replaceWaits(tx, id, release) {
758
+ const d = new SqlParams();
759
+ await tx.execute(`DELETE FROM ${this.t.waits} WHERE instance_id = ${d.text(id)}`, d.values);
760
+ if (release.waits.length === 0) {
761
+ return false;
762
+ }
763
+ for (let start = 0; start < release.waits.length; start += ROWS_PER_INSERT) {
764
+ const p = new SqlParams();
765
+ const rows = release.waits
766
+ .slice(start, start + ROWS_PER_INSERT)
767
+ .map((wait, offset) => `(${p.text(id)}, ${p.int(start + offset)}, ${p.text(wait.signal)}, ${p.text(wait.key)})`);
768
+ await tx.execute(`INSERT INTO ${this.t.waits} (instance_id, position, \`signal\`, \`key\`) VALUES ${rows.join(', ')}`, p.values);
769
+ }
770
+ const m = new SqlParams();
771
+ const [hit] = await tx.query(`SELECT CAST(s.id AS CHAR) AS id FROM ${this.t.signals} s
772
+ WHERE s.id > ${m.bigint(release.signalCursor)} AND (${release.waits.map((wait) => `(s.name = ${m.text(wait.signal)} AND ${m.equals('s.`key`', wait.key)})`).join(' OR ')})
773
+ LIMIT 1`, m.values);
774
+ return hit !== undefined;
775
+ }
776
+ /** Journal entries by name: a new name goes last (`seq`), a known one is replaced in place. */
777
+ async upsertEntries(tx, id, entries) {
778
+ for (let start = 0; start < entries.length; start += ROWS_PER_INSERT) {
779
+ const p = new SqlParams();
780
+ const rows = entries.slice(start, start + ROWS_PER_INSERT).map((entry) => `(${p.text(id)}, ${p.text(entry.name)}, ${p.json(entry)})`);
781
+ // The row alias names the values of the row being inserted (VALUES() is deprecated). The count isn't read.
782
+ await tx.execute(`INSERT INTO ${this.t.journal} (instance_id, name, entry) VALUES ${rows.join(', ')} AS v ON DUPLICATE KEY UPDATE entry = v.entry`, p.values);
783
+ }
784
+ }
785
+ /** Deletes the journals and waits of instances deleted in this transaction. */
786
+ async deleteChildren(tx, ids) {
787
+ for (const table of [this.t.journal, this.t.waits]) {
788
+ const p = new SqlParams();
789
+ await tx.execute(`DELETE FROM ${table} WHERE ${p.in('instance_id', ids)}`, p.values);
790
+ }
791
+ }
792
+ async readSchedule(db, id) {
793
+ const p = new SqlParams();
794
+ const [row] = await db.query(`SELECT ${columns(SCHEDULE_COLUMNS, 's')} FROM ${this.t.schedules} s WHERE s.id = ${p.text(id)}`, p.values);
795
+ return row ? toSchedule(row) : null;
796
+ }
797
+ /** Due, unleased instances of the versions the worker runs, as `alias` in a statement of `p`. */
798
+ isDue(p, request, alias) {
799
+ return `${alias}.wake_at IS NOT NULL AND ${alias}.wake_at <= ${p.bigint(request.now)} AND ${alias}.status IN ${RUNNABLE}
800
+ AND (${alias}.lease_until IS NULL OR ${alias}.lease_until < ${p.bigint(request.now)})
801
+ AND (${alias}.workflow, ${alias}.version) IN (${request.workflows.map((w) => `(${p.text(w.name)}, ${p.int(w.version)})`).join(', ')})`;
802
+ }
803
+ async lastSignalId(db) {
804
+ const [row] = await db.query(`SELECT CAST(COALESCE(MAX(s.id), 0) AS CHAR) AS id FROM ${this.t.signals} s`);
805
+ return toInt(row?.id) ?? 0;
806
+ }
807
+ }
808
+ /** The schema version this version of the package needs: its last migration. */
809
+ MySqlWorkflowStore.schemaVersion = mysqlWorkflowStoreSchema.latest;
810
+ /** The keys a new instance brings, checked against their columns' lengths. */
811
+ function checkInstance(i) {
812
+ checkKey('an instance id', i.id, L.instanceId);
813
+ checkKey("a workflow's name", i.workflow, L.workflow);
814
+ checkKey("an instance's parent id", i.parentId, L.instanceId);
815
+ checkKey('a concurrency key', i.concurrencyKey, L.concurrencyKey);
816
+ checkKey('a rate-limit key', i.rateLimitKey, L.rateLimitKey);
817
+ checkKey('a schedule id', i.scheduleId, L.scheduleId);
818
+ }
819
+ function checkSignal(s) {
820
+ checkKey("a signal's name", s.name, L.signal);
821
+ checkKey("a signal's key", s.key, L.signalKey);
822
+ checkKey("a signal's id", s.dedupeId, L.dedupeId);
823
+ }
824
+ function checkEntries(entries) {
825
+ for (const entry of entries) {
826
+ checkKey("a journal entry's name (a step's, a wait's, or the engine's, such as $child: and a child's id)", entry.name, L.journalName);
827
+ }
828
+ }