@sonamu-kit/tasks 0.1.3 → 0.3.0

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 (215) hide show
  1. package/.oxlintrc.json +3 -0
  2. package/AGENTS.md +21 -0
  3. package/dist/backend.d.ts +126 -103
  4. package/dist/backend.d.ts.map +1 -1
  5. package/dist/backend.js +4 -1
  6. package/dist/backend.js.map +1 -1
  7. package/dist/client.d.ts +145 -132
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +220 -212
  10. package/dist/client.js.map +1 -1
  11. package/dist/config.d.ts +15 -8
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +22 -17
  14. package/dist/config.js.map +1 -1
  15. package/dist/core/duration.d.ts +5 -4
  16. package/dist/core/duration.d.ts.map +1 -1
  17. package/dist/core/duration.js +54 -59
  18. package/dist/core/duration.js.map +1 -1
  19. package/dist/core/error.d.ts +10 -7
  20. package/dist/core/error.d.ts.map +1 -1
  21. package/dist/core/error.js +21 -21
  22. package/dist/core/error.js.map +1 -1
  23. package/dist/core/json.d.ts +8 -3
  24. package/dist/core/json.d.ts.map +1 -1
  25. package/dist/core/result.d.ts +10 -14
  26. package/dist/core/result.d.ts.map +1 -1
  27. package/dist/core/result.js +21 -16
  28. package/dist/core/result.js.map +1 -1
  29. package/dist/core/retry.d.ts +42 -20
  30. package/dist/core/retry.d.ts.map +1 -1
  31. package/dist/core/retry.js +49 -20
  32. package/dist/core/retry.js.map +1 -1
  33. package/dist/core/schema.d.ts +57 -53
  34. package/dist/core/schema.d.ts.map +1 -1
  35. package/dist/core/step.d.ts +28 -78
  36. package/dist/core/step.d.ts.map +1 -1
  37. package/dist/core/step.js +53 -63
  38. package/dist/core/step.js.map +1 -1
  39. package/dist/core/workflow.d.ts +33 -61
  40. package/dist/core/workflow.d.ts.map +1 -1
  41. package/dist/core/workflow.js +31 -41
  42. package/dist/core/workflow.js.map +1 -1
  43. package/dist/database/backend.d.ts +53 -46
  44. package/dist/database/backend.d.ts.map +1 -1
  45. package/dist/database/backend.js +544 -545
  46. package/dist/database/backend.js.map +1 -1
  47. package/dist/database/base.js +48 -25
  48. package/dist/database/base.js.map +1 -1
  49. package/dist/database/migrations/20251212000000_0_init.d.ts +10 -0
  50. package/dist/database/migrations/20251212000000_0_init.d.ts.map +1 -0
  51. package/dist/database/migrations/20251212000000_0_init.js +8 -4
  52. package/dist/database/migrations/20251212000000_0_init.js.map +1 -1
  53. package/dist/database/migrations/20251212000000_1_tables.d.ts +10 -0
  54. package/dist/database/migrations/20251212000000_1_tables.d.ts.map +1 -0
  55. package/dist/database/migrations/20251212000000_1_tables.js +81 -83
  56. package/dist/database/migrations/20251212000000_1_tables.js.map +1 -1
  57. package/dist/database/migrations/20251212000000_2_fk.d.ts +10 -0
  58. package/dist/database/migrations/20251212000000_2_fk.d.ts.map +1 -0
  59. package/dist/database/migrations/20251212000000_2_fk.js +20 -43
  60. package/dist/database/migrations/20251212000000_2_fk.js.map +1 -1
  61. package/dist/database/migrations/20251212000000_3_indexes.d.ts +10 -0
  62. package/dist/database/migrations/20251212000000_3_indexes.d.ts.map +1 -0
  63. package/dist/database/migrations/20251212000000_3_indexes.js +88 -102
  64. package/dist/database/migrations/20251212000000_3_indexes.js.map +1 -1
  65. package/dist/database/pubsub.d.ts +7 -16
  66. package/dist/database/pubsub.d.ts.map +1 -1
  67. package/dist/database/pubsub.js +75 -73
  68. package/dist/database/pubsub.js.map +1 -1
  69. package/dist/execution.d.ts +20 -57
  70. package/dist/execution.d.ts.map +1 -1
  71. package/dist/execution.js +175 -174
  72. package/dist/execution.js.map +1 -1
  73. package/dist/index.d.ts +5 -8
  74. package/dist/index.js +5 -5
  75. package/dist/internal.d.ts +12 -12
  76. package/dist/internal.js +4 -4
  77. package/dist/registry.d.ts +33 -27
  78. package/dist/registry.d.ts.map +1 -1
  79. package/dist/registry.js +58 -49
  80. package/dist/registry.js.map +1 -1
  81. package/dist/worker.d.ts +57 -50
  82. package/dist/worker.d.ts.map +1 -1
  83. package/dist/worker.js +194 -198
  84. package/dist/worker.js.map +1 -1
  85. package/dist/workflow.d.ts +26 -27
  86. package/dist/workflow.d.ts.map +1 -1
  87. package/dist/workflow.js +20 -15
  88. package/dist/workflow.js.map +1 -1
  89. package/nodemon.json +1 -1
  90. package/package.json +18 -20
  91. package/src/backend.ts +28 -8
  92. package/src/chaos.test.ts +3 -1
  93. package/src/client.test.ts +2 -0
  94. package/src/client.ts +32 -8
  95. package/src/config.test.ts +1 -0
  96. package/src/config.ts +3 -2
  97. package/src/core/duration.test.ts +2 -1
  98. package/src/core/duration.ts +1 -1
  99. package/src/core/error.test.ts +1 -0
  100. package/src/core/error.ts +1 -1
  101. package/src/core/result.test.ts +1 -0
  102. package/src/core/retry.test.ts +181 -11
  103. package/src/core/retry.ts +95 -19
  104. package/src/core/schema.ts +2 -2
  105. package/src/core/step.test.ts +2 -1
  106. package/src/core/step.ts +4 -3
  107. package/src/core/workflow.test.ts +2 -1
  108. package/src/core/workflow.ts +4 -3
  109. package/src/database/backend.test.ts +1 -0
  110. package/src/database/backend.testsuite.ts +162 -39
  111. package/src/database/backend.ts +271 -35
  112. package/src/database/base.test.ts +41 -0
  113. package/src/database/base.ts +51 -2
  114. package/src/database/migrations/20251212000000_0_init.ts +2 -1
  115. package/src/database/migrations/20251212000000_1_tables.ts +2 -1
  116. package/src/database/migrations/20251212000000_2_fk.ts +2 -1
  117. package/src/database/migrations/20251212000000_3_indexes.ts +2 -1
  118. package/src/database/pubsub.test.ts +6 -3
  119. package/src/database/pubsub.ts +55 -33
  120. package/src/execution.test.ts +117 -0
  121. package/src/execution.ts +65 -10
  122. package/src/internal.ts +21 -1
  123. package/src/practices/01-remote-workflow.ts +1 -0
  124. package/src/registry.test.ts +1 -0
  125. package/src/registry.ts +1 -1
  126. package/src/testing/connection.ts +3 -1
  127. package/src/worker.test.ts +2 -0
  128. package/src/worker.ts +31 -9
  129. package/src/workflow.test.ts +1 -0
  130. package/src/workflow.ts +5 -2
  131. package/templates/openworkflow.config.ts +2 -1
  132. package/tsdown.config.ts +31 -0
  133. package/.swcrc +0 -17
  134. package/dist/chaos.test.d.ts +0 -2
  135. package/dist/chaos.test.d.ts.map +0 -1
  136. package/dist/chaos.test.js +0 -92
  137. package/dist/chaos.test.js.map +0 -1
  138. package/dist/client.test.d.ts +0 -2
  139. package/dist/client.test.d.ts.map +0 -1
  140. package/dist/client.test.js +0 -340
  141. package/dist/client.test.js.map +0 -1
  142. package/dist/config.test.d.ts +0 -2
  143. package/dist/config.test.d.ts.map +0 -1
  144. package/dist/config.test.js +0 -24
  145. package/dist/config.test.js.map +0 -1
  146. package/dist/core/duration.test.d.ts +0 -2
  147. package/dist/core/duration.test.d.ts.map +0 -1
  148. package/dist/core/duration.test.js +0 -265
  149. package/dist/core/duration.test.js.map +0 -1
  150. package/dist/core/error.test.d.ts +0 -2
  151. package/dist/core/error.test.d.ts.map +0 -1
  152. package/dist/core/error.test.js +0 -63
  153. package/dist/core/error.test.js.map +0 -1
  154. package/dist/core/json.js +0 -3
  155. package/dist/core/json.js.map +0 -1
  156. package/dist/core/result.test.d.ts +0 -2
  157. package/dist/core/result.test.d.ts.map +0 -1
  158. package/dist/core/result.test.js +0 -19
  159. package/dist/core/result.test.js.map +0 -1
  160. package/dist/core/retry.test.d.ts +0 -2
  161. package/dist/core/retry.test.d.ts.map +0 -1
  162. package/dist/core/retry.test.js +0 -37
  163. package/dist/core/retry.test.js.map +0 -1
  164. package/dist/core/schema.js +0 -4
  165. package/dist/core/schema.js.map +0 -1
  166. package/dist/core/step.test.d.ts +0 -2
  167. package/dist/core/step.test.d.ts.map +0 -1
  168. package/dist/core/step.test.js +0 -356
  169. package/dist/core/step.test.js.map +0 -1
  170. package/dist/core/workflow.test.d.ts +0 -2
  171. package/dist/core/workflow.test.d.ts.map +0 -1
  172. package/dist/core/workflow.test.js +0 -172
  173. package/dist/core/workflow.test.js.map +0 -1
  174. package/dist/database/backend.test.d.ts +0 -2
  175. package/dist/database/backend.test.d.ts.map +0 -1
  176. package/dist/database/backend.test.js +0 -19
  177. package/dist/database/backend.test.js.map +0 -1
  178. package/dist/database/backend.testsuite.d.ts +0 -20
  179. package/dist/database/backend.testsuite.d.ts.map +0 -1
  180. package/dist/database/backend.testsuite.js +0 -1174
  181. package/dist/database/backend.testsuite.js.map +0 -1
  182. package/dist/database/base.d.ts +0 -12
  183. package/dist/database/base.d.ts.map +0 -1
  184. package/dist/database/pubsub.test.d.ts +0 -2
  185. package/dist/database/pubsub.test.d.ts.map +0 -1
  186. package/dist/database/pubsub.test.js +0 -86
  187. package/dist/database/pubsub.test.js.map +0 -1
  188. package/dist/execution.test.d.ts +0 -2
  189. package/dist/execution.test.d.ts.map +0 -1
  190. package/dist/execution.test.js +0 -558
  191. package/dist/execution.test.js.map +0 -1
  192. package/dist/index.d.ts.map +0 -1
  193. package/dist/index.js.map +0 -1
  194. package/dist/internal.d.ts.map +0 -1
  195. package/dist/internal.js.map +0 -1
  196. package/dist/practices/01-remote-workflow.d.ts +0 -2
  197. package/dist/practices/01-remote-workflow.d.ts.map +0 -1
  198. package/dist/practices/01-remote-workflow.js +0 -70
  199. package/dist/practices/01-remote-workflow.js.map +0 -1
  200. package/dist/registry.test.d.ts +0 -2
  201. package/dist/registry.test.d.ts.map +0 -1
  202. package/dist/registry.test.js +0 -95
  203. package/dist/registry.test.js.map +0 -1
  204. package/dist/testing/connection.d.ts +0 -7
  205. package/dist/testing/connection.d.ts.map +0 -1
  206. package/dist/testing/connection.js +0 -39
  207. package/dist/testing/connection.js.map +0 -1
  208. package/dist/worker.test.d.ts +0 -2
  209. package/dist/worker.test.d.ts.map +0 -1
  210. package/dist/worker.test.js +0 -1164
  211. package/dist/worker.test.js.map +0 -1
  212. package/dist/workflow.test.d.ts +0 -2
  213. package/dist/workflow.test.d.ts.map +0 -1
  214. package/dist/workflow.test.js +0 -73
  215. package/dist/workflow.test.js.map +0 -1
@@ -1,559 +1,558 @@
1
- import { getLogger } from "@logtape/logtape";
2
- import { camelize } from "inflection";
3
- import knex from "knex";
1
+ import { mergeRetryPolicy } from "../core/retry.js";
4
2
  import { DEFAULT_NAMESPACE_ID } from "../backend.js";
5
- import { DEFAULT_RETRY_POLICY } from "../core/retry.js";
6
3
  import { DEFAULT_SCHEMA, migrate } from "./base.js";
7
4
  import { PostgresPubSub } from "./pubsub.js";
8
- export const DEFAULT_LISTEN_CHANNEL = "new_tasks";
5
+ import { getLogger } from "@logtape/logtape";
6
+ import { camelize } from "inflection";
7
+ import knex from "knex";
8
+
9
+ //#region src/database/backend.ts
10
+ const DEFAULT_LISTEN_CHANNEL = "new_tasks";
9
11
  const DEFAULT_PAGINATION_PAGE_SIZE = 100;
10
12
  const logger = getLogger([
11
- "sonamu",
12
- "internal",
13
- "tasks"
13
+ "sonamu",
14
+ "internal",
15
+ "tasks"
14
16
  ]);
15
17
  const queryLogger = getLogger([
16
- "sonamu",
17
- "internal",
18
- "tasks",
19
- "query"
18
+ "sonamu",
19
+ "internal",
20
+ "tasks",
21
+ "query"
20
22
  ]);
21
23
  /**
22
- * Manages a connection to a Postgres database for workflow operations.
23
- */ export class BackendPostgres {
24
- config;
25
- namespaceId;
26
- usePubSub;
27
- pubsub = null;
28
- initialized = false;
29
- runMigrations;
30
- _knex = null;
31
- get knex() {
32
- if (!this._knex) {
33
- this._knex = knex(this.config);
34
- this._knex.on("query", (query)=>{
35
- queryLogger.debug("SQL: {query}, Values: {bindings}", {
36
- query: query.sql,
37
- bindings: query.bindings
38
- });
39
- });
40
- }
41
- return this._knex;
42
- }
43
- constructor(config, options){
44
- this.config = {
45
- ...config,
46
- postProcessResponse: (result, _queryContext)=>{
47
- if (result === null || result === undefined) {
48
- return result;
49
- }
50
- if (config?.postProcessResponse) {
51
- result = config.postProcessResponse(result, _queryContext);
52
- }
53
- const camelizeRow = (row)=>Object.fromEntries(Object.entries(row).map(([key, value])=>[
54
- camelize(key, true),
55
- value
56
- ]));
57
- if (Array.isArray(result)) {
58
- return result.map(camelizeRow);
59
- }
60
- return camelizeRow(result);
61
- }
62
- };
63
- const { namespaceId, usePubSub, runMigrations } = {
64
- namespaceId: DEFAULT_NAMESPACE_ID,
65
- usePubSub: true,
66
- runMigrations: true,
67
- ...options
68
- };
69
- this.namespaceId = namespaceId;
70
- this.usePubSub = usePubSub;
71
- this.runMigrations = runMigrations;
72
- }
73
- async initialize() {
74
- if (this.initialized) {
75
- return;
76
- }
77
- if (this.runMigrations) {
78
- await migrate(this.config, DEFAULT_SCHEMA);
79
- }
80
- this.initialized = true;
81
- }
82
- async subscribe(callback) {
83
- if (!this.initialized) {
84
- throw new Error("Backend not initialized");
85
- }
86
- if (!this.usePubSub) {
87
- return;
88
- }
89
- if (!this.pubsub) {
90
- this.pubsub = await PostgresPubSub.create(this.knex);
91
- }
92
- this.pubsub.listenEvent(DEFAULT_LISTEN_CHANNEL, callback);
93
- }
94
- async publish(payload) {
95
- if (!this.initialized) {
96
- throw new Error("Backend not initialized");
97
- }
98
- if (!this.usePubSub) {
99
- return;
100
- }
101
- await this.knex.raw(payload ? `NOTIFY ${DEFAULT_LISTEN_CHANNEL}, '${payload}'` : `NOTIFY ${DEFAULT_LISTEN_CHANNEL}`);
102
- }
103
- async stop() {
104
- if (!this.initialized) {
105
- return;
106
- }
107
- await this.pubsub?.destroy();
108
- this.pubsub = null;
109
- await this.knex.destroy();
110
- }
111
- async createWorkflowRun(params) {
112
- if (!this.initialized) {
113
- throw new Error("Backend not initialized");
114
- }
115
- logger.info("Creating workflow run: {workflowName}:{version}", {
116
- workflowName: params.workflowName,
117
- version: params.version
118
- });
119
- const qb = this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").insert({
120
- namespace_id: this.namespaceId,
121
- id: crypto.randomUUID(),
122
- workflow_name: params.workflowName,
123
- version: params.version,
124
- status: "pending",
125
- idempotency_key: params.idempotencyKey,
126
- config: params.config,
127
- context: params.context,
128
- input: params.input,
129
- attempts: 0,
130
- available_at: params.availableAt ?? this.knex.fn.now(),
131
- deadline_at: params.deadlineAt,
132
- created_at: this.knex.fn.now(),
133
- updated_at: this.knex.fn.now()
134
- }).returning("*");
135
- const workflowRun = await qb;
136
- if (!workflowRun[0]) {
137
- logger.error("Failed to create workflow run: {params}", {
138
- params
139
- });
140
- throw new Error("Failed to create workflow run");
141
- }
142
- return workflowRun[0];
143
- }
144
- async getWorkflowRun(params) {
145
- if (!this.initialized) {
146
- throw new Error("Backend not initialized");
147
- }
148
- logger.info("Getting workflow run: {workflowRunId}", {
149
- workflowRunId: params.workflowRunId
150
- });
151
- const workflowRun = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).select("namespace_id", "id", "workflow_name", "version", "status", "idempotency_key", "config", "context", "input", "output", "error", "attempts", "parent_step_attempt_namespace_id", "parent_step_attempt_id", "worker_id", "available_at", "deadline_at", "started_at", "finished_at", "created_at", "updated_at").first();
152
- return workflowRun ?? null;
153
- }
154
- async listWorkflowRuns(params) {
155
- if (!this.initialized) {
156
- throw new Error("Backend not initialized");
157
- }
158
- logger.info("Listing workflow runs: {after}, {before}", {
159
- after: params.after,
160
- before: params.before
161
- });
162
- const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
163
- const { after, before } = params;
164
- let cursor = null;
165
- if (after) {
166
- cursor = decodeCursor(after);
167
- } else if (before) {
168
- cursor = decodeCursor(before);
169
- }
170
- const qb = this.buildListWorkflowRunsWhere(params, cursor);
171
- const rows = await qb.orderBy("created_at", before ? "desc" : "asc").orderBy("id", before ? "desc" : "asc").limit(limit + 1);
172
- return this.processPaginationResults(rows, limit, typeof after === "string", typeof before === "string");
173
- }
174
- buildListWorkflowRunsWhere(params, cursor) {
175
- const { after } = params;
176
- const qb = this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId);
177
- if (cursor) {
178
- const operator = after ? ">" : "<";
179
- return qb.whereRaw(`("created_at", "id") ${operator} (?, ?)`, [
180
- cursor.createdAt.toISOString(),
181
- cursor.id
182
- ]);
183
- }
184
- return qb;
185
- }
186
- async claimWorkflowRun(params) {
187
- if (!this.initialized) {
188
- throw new Error("Backend not initialized");
189
- }
190
- logger.info("Claiming workflow run: {workerId}, {leaseDurationMs}", {
191
- workerId: params.workerId,
192
- leaseDurationMs: params.leaseDurationMs
193
- });
194
- const claimed = await this.knex.with("expired", (qb)=>qb.withSchema(DEFAULT_SCHEMA).table("workflow_runs").update({
195
- status: "failed",
196
- error: JSON.stringify({
197
- message: "Workflow run deadline exceeded"
198
- }),
199
- worker_id: null,
200
- available_at: null,
201
- finished_at: this.knex.raw("NOW()"),
202
- updated_at: this.knex.raw("NOW()")
203
- }).where("namespace_id", this.namespaceId).whereIn("status", [
204
- "pending",
205
- "running",
206
- "sleeping"
207
- ]).whereNotNull("deadline_at").where("deadline_at", "<=", this.knex.raw("NOW()")).returning("id")).with("candidate", (qb)=>qb.withSchema(DEFAULT_SCHEMA).select("id").from("workflow_runs").where("namespace_id", this.namespaceId).whereIn("status", [
208
- "pending",
209
- "running",
210
- "sleeping"
211
- ]).where("available_at", "<=", this.knex.raw("NOW()")).where((qb2)=>{
212
- qb2.whereNull("deadline_at").orWhere("deadline_at", ">", this.knex.raw("NOW()"));
213
- }).orderByRaw("CASE WHEN status = 'pending' THEN 0 ELSE 1 END").orderBy("available_at", "asc").orderBy("created_at", "asc").limit(1).forUpdate().skipLocked()).withSchema(DEFAULT_SCHEMA).table("workflow_runs as wr").where("wr.namespace_id", this.namespaceId).where("wr.id", this.knex.ref("candidate.id")).update({
214
- status: "running",
215
- attempts: this.knex.raw("wr.attempts + 1"),
216
- worker_id: params.workerId,
217
- available_at: this.knex.raw(`NOW() + ${params.leaseDurationMs} * INTERVAL '1 millisecond'`),
218
- started_at: this.knex.raw("COALESCE(wr.started_at, NOW())"),
219
- updated_at: this.knex.raw("NOW()")
220
- }).updateFrom("candidate").returning("wr.*");
221
- return claimed[0] ?? null;
222
- }
223
- async extendWorkflowRunLease(params) {
224
- if (!this.initialized) {
225
- throw new Error("Backend not initialized");
226
- }
227
- logger.info("Extending workflow run lease: {workflowRunId}, {workerId}, {leaseDurationMs}", {
228
- workflowRunId: params.workflowRunId,
229
- workerId: params.workerId,
230
- leaseDurationMs: params.leaseDurationMs
231
- });
232
- const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).where("status", "running").where("worker_id", params.workerId).update({
233
- available_at: this.knex.raw(`NOW() + ${params.leaseDurationMs} * INTERVAL '1 millisecond'`),
234
- updated_at: this.knex.fn.now()
235
- }).returning("*");
236
- if (!updated) {
237
- logger.error("Failed to extend lease for workflow run: {params}", {
238
- params
239
- });
240
- throw new Error("Failed to extend lease for workflow run");
241
- }
242
- return updated;
243
- }
244
- async sleepWorkflowRun(params) {
245
- if (!this.initialized) {
246
- throw new Error("Backend not initialized");
247
- }
248
- logger.info("Sleeping workflow run: {workflowRunId}, {workerId}, {availableAt}", {
249
- workflowRunId: params.workflowRunId,
250
- workerId: params.workerId,
251
- availableAt: params.availableAt
252
- });
253
- // 'succeeded' status is deprecated
254
- const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).whereNotIn("status", [
255
- "succeeded",
256
- "completed",
257
- "failed",
258
- "canceled"
259
- ]).where("worker_id", params.workerId).update({
260
- status: "sleeping",
261
- available_at: params.availableAt,
262
- worker_id: null,
263
- updated_at: this.knex.fn.now()
264
- }).returning("*");
265
- if (!updated) {
266
- logger.error("Failed to sleep workflow run: {params}", {
267
- params
268
- });
269
- throw new Error("Failed to sleep workflow run");
270
- }
271
- return updated;
272
- }
273
- async completeWorkflowRun(params) {
274
- if (!this.initialized) {
275
- throw new Error("Backend not initialized");
276
- }
277
- logger.info("Completing workflow run: {workflowRunId}, {workerId}, {output}", {
278
- workflowRunId: params.workflowRunId,
279
- workerId: params.workerId,
280
- output: params.output
281
- });
282
- const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).where("status", "running").where("worker_id", params.workerId).update({
283
- status: "completed",
284
- output: JSON.stringify(params.output),
285
- error: null,
286
- worker_id: params.workerId,
287
- available_at: null,
288
- finished_at: this.knex.fn.now(),
289
- updated_at: this.knex.fn.now()
290
- }).returning("*");
291
- if (!updated) {
292
- logger.error("Failed to complete workflow run: {params}", {
293
- params
294
- });
295
- throw new Error("Failed to complete workflow run");
296
- }
297
- return updated;
298
- }
299
- async failWorkflowRun(params) {
300
- if (!this.initialized) {
301
- throw new Error("Backend not initialized");
302
- }
303
- const { workflowRunId, error } = params;
304
- const { initialIntervalMs, backoffCoefficient, maximumIntervalMs } = DEFAULT_RETRY_POLICY;
305
- // this beefy query updates a workflow's status, available_at, and
306
- // finished_at based on the workflow's deadline and retry policy
307
- //
308
- // if the next retry would exceed the deadline, the run is marked as
309
- // 'failed' and finalized, otherwise, the run is rescheduled with an updated
310
- // 'available_at' timestamp for the next retry
311
- const retryIntervalExpr = `LEAST(${initialIntervalMs} * POWER(${backoffCoefficient}, "attempts" - 1), ${maximumIntervalMs}) * INTERVAL '1 millisecond'`;
312
- const deadlineExceededCondition = `"deadline_at" IS NOT NULL AND NOW() + (${retryIntervalExpr}) >= "deadline_at"`;
313
- logger.info("Failing workflow run: {workflowRunId}, {workerId}, {error}", {
314
- workflowRunId: params.workflowRunId,
315
- workerId: params.workerId,
316
- error: params.error
317
- });
318
- const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", workflowRunId).where("status", "running").where("worker_id", params.workerId).update({
319
- status: this.knex.raw(`CASE WHEN ${deadlineExceededCondition} THEN 'failed' ELSE 'pending' END`),
320
- available_at: this.knex.raw(`CASE WHEN ${deadlineExceededCondition} THEN NULL ELSE NOW() + (${retryIntervalExpr}) END`),
321
- finished_at: this.knex.raw(`CASE WHEN ${deadlineExceededCondition} THEN NOW() ELSE NULL END`),
322
- error: JSON.stringify(error),
323
- worker_id: null,
324
- started_at: null,
325
- updated_at: this.knex.fn.now()
326
- }).returning("*");
327
- if (!updated) {
328
- logger.error("Failed to mark workflow run failed: {params}", {
329
- params
330
- });
331
- throw new Error("Failed to mark workflow run failed");
332
- }
333
- return updated;
334
- }
335
- async cancelWorkflowRun(params) {
336
- if (!this.initialized) {
337
- throw new Error("Backend not initialized");
338
- }
339
- logger.info("Canceling workflow run: {workflowRunId}", {
340
- workflowRunId: params.workflowRunId
341
- });
342
- const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).whereIn("status", [
343
- "pending",
344
- "running",
345
- "sleeping"
346
- ]).update({
347
- status: "canceled",
348
- worker_id: null,
349
- available_at: null,
350
- finished_at: this.knex.fn.now(),
351
- updated_at: this.knex.fn.now()
352
- }).returning("*");
353
- if (!updated) {
354
- // workflow may already be in a terminal state
355
- const existing = await this.getWorkflowRun({
356
- workflowRunId: params.workflowRunId
357
- });
358
- if (!existing) {
359
- throw new Error(`Workflow run ${params.workflowRunId} does not exist`);
360
- }
361
- // if already canceled, just return it
362
- if (existing.status === "canceled") {
363
- return existing;
364
- }
365
- // throw error for completed/failed workflows
366
- // 'succeeded' status is deprecated
367
- if ([
368
- "succeeded",
369
- "completed",
370
- "failed"
371
- ].includes(existing.status)) {
372
- logger.error("Cannot cancel workflow run: {params} with status {status}", {
373
- params,
374
- status: existing.status
375
- });
376
- throw new Error(`Cannot cancel workflow run ${params.workflowRunId} with status ${existing.status}`);
377
- }
378
- logger.error("Failed to cancel workflow run: {params}", {
379
- params
380
- });
381
- throw new Error("Failed to cancel workflow run");
382
- }
383
- return updated;
384
- }
385
- async createStepAttempt(params) {
386
- if (!this.initialized) {
387
- throw new Error("Backend not initialized");
388
- }
389
- logger.info("Creating step attempt: {workflowRunId}, {stepName}, {kind}", {
390
- workflowRunId: params.workflowRunId,
391
- stepName: params.stepName,
392
- kind: params.kind
393
- });
394
- const [stepAttempt] = await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts").insert({
395
- namespace_id: this.namespaceId,
396
- id: crypto.randomUUID(),
397
- workflow_run_id: params.workflowRunId,
398
- step_name: params.stepName,
399
- kind: params.kind,
400
- status: "running",
401
- config: JSON.stringify(params.config),
402
- context: JSON.stringify(params.context),
403
- started_at: this.knex.fn.now(),
404
- created_at: this.knex.raw("date_trunc('milliseconds', NOW())"),
405
- updated_at: this.knex.fn.now()
406
- }).returning("*");
407
- if (!stepAttempt) {
408
- logger.error("Failed to create step attempt: {params}", {
409
- params
410
- });
411
- throw new Error("Failed to create step attempt");
412
- }
413
- return stepAttempt;
414
- }
415
- async getStepAttempt(params) {
416
- if (!this.initialized) {
417
- throw new Error("Backend not initialized");
418
- }
419
- logger.info("Getting step attempt: {stepAttemptId}", {
420
- stepAttemptId: params.stepAttemptId
421
- });
422
- const stepAttempt = await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts").where("namespace_id", this.namespaceId).where("id", params.stepAttemptId).first();
423
- return stepAttempt ?? null;
424
- }
425
- async listStepAttempts(params) {
426
- if (!this.initialized) {
427
- throw new Error("Backend not initialized");
428
- }
429
- logger.info("Listing step attempts: {workflowRunId}, {after}, {before}", {
430
- workflowRunId: params.workflowRunId,
431
- after: params.after,
432
- before: params.before
433
- });
434
- const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
435
- const { after, before } = params;
436
- let cursor = null;
437
- if (after) {
438
- cursor = decodeCursor(after);
439
- } else if (before) {
440
- cursor = decodeCursor(before);
441
- }
442
- const qb = this.buildListStepAttemptsWhere(params, cursor);
443
- const rows = await qb.orderBy("created_at", before ? "desc" : "asc").orderBy("id", before ? "desc" : "asc").limit(limit + 1);
444
- return this.processPaginationResults(rows, limit, typeof after === "string", typeof before === "string");
445
- }
446
- buildListStepAttemptsWhere(params, cursor) {
447
- const { after } = params;
448
- const qb = this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts").where("namespace_id", this.namespaceId).where("workflow_run_id", params.workflowRunId);
449
- if (cursor) {
450
- const operator = after ? ">" : "<";
451
- return qb.whereRaw(`("created_at", "id") ${operator} (?, ?)`, [
452
- cursor.createdAt.toISOString(),
453
- cursor.id
454
- ]);
455
- }
456
- return qb;
457
- }
458
- processPaginationResults(rows, limit, hasAfter, hasBefore) {
459
- const data = rows;
460
- let hasNext = false;
461
- let hasPrev = false;
462
- if (hasBefore) {
463
- data.reverse();
464
- if (data.length > limit) {
465
- hasPrev = true;
466
- data.shift();
467
- }
468
- hasNext = true;
469
- } else {
470
- if (data.length > limit) {
471
- hasNext = true;
472
- data.pop();
473
- }
474
- if (hasAfter) {
475
- hasPrev = true;
476
- }
477
- }
478
- const lastItem = data.at(-1);
479
- const nextCursor = hasNext && lastItem ? encodeCursor(lastItem) : null;
480
- const firstItem = data[0];
481
- const prevCursor = hasPrev && firstItem ? encodeCursor(firstItem) : null;
482
- return {
483
- data,
484
- pagination: {
485
- next: nextCursor,
486
- prev: prevCursor
487
- }
488
- };
489
- }
490
- // NOTE: 실제 서비스에서 이게 안 되는 것 같은데, 쿼리 등을 체크할 필요가 있음.
491
- async completeStepAttempt(params) {
492
- if (!this.initialized) {
493
- throw new Error("Backend not initialized");
494
- }
495
- logger.info("Marking step attempt as completed: {workflowRunId}, {stepAttemptId}, {workerId}", {
496
- workflowRunId: params.workflowRunId,
497
- stepAttemptId: params.stepAttemptId,
498
- workerId: params.workerId
499
- });
500
- const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts as sa").update({
501
- status: "completed",
502
- output: JSON.stringify(params.output),
503
- error: null,
504
- finished_at: this.knex.fn.now(),
505
- updated_at: this.knex.fn.now()
506
- }).updateFrom(`${DEFAULT_SCHEMA}.workflow_runs as wr`).where("sa.namespace_id", this.namespaceId).where("sa.workflow_run_id", params.workflowRunId).where("sa.id", params.stepAttemptId).where("sa.status", "running").where("wr.namespace_id", this.knex.ref("sa.namespace_id")).where("wr.id", this.knex.ref("sa.workflow_run_id")).where("wr.status", "running").where("wr.worker_id", params.workerId).returning("sa.*");
507
- if (!updated) {
508
- logger.error("Failed to mark step attempt completed: {params}", {
509
- params
510
- });
511
- throw new Error("Failed to mark step attempt completed");
512
- }
513
- return updated;
514
- }
515
- async failStepAttempt(params) {
516
- if (!this.initialized) {
517
- throw new Error("Backend not initialized");
518
- }
519
- logger.info("Marking step attempt as failed: {workflowRunId}, {stepAttemptId}, {workerId}", {
520
- workflowRunId: params.workflowRunId,
521
- stepAttemptId: params.stepAttemptId,
522
- workerId: params.workerId
523
- });
524
- logger.info("Error: {error.message}", {
525
- error: params.error.message
526
- });
527
- const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts as sa").update({
528
- status: "failed",
529
- output: null,
530
- error: JSON.stringify(params.error),
531
- finished_at: this.knex.fn.now(),
532
- updated_at: this.knex.fn.now()
533
- }).updateFrom(`${DEFAULT_SCHEMA}.workflow_runs as wr`).where("sa.namespace_id", this.namespaceId).where("sa.workflow_run_id", params.workflowRunId).where("sa.id", params.stepAttemptId).where("sa.status", "running").where("wr.namespace_id", this.knex.ref("sa.namespace_id")).where("wr.id", this.knex.ref("sa.workflow_run_id")).where("wr.status", "running").where("wr.worker_id", params.workerId).returning("sa.*");
534
- if (!updated) {
535
- logger.error("Failed to mark step attempt failed: {params}", {
536
- params
537
- });
538
- throw new Error("Failed to mark step attempt failed");
539
- }
540
- return updated;
541
- }
542
- }
24
+ * Manages a connection to a Postgres database for workflow operations.
25
+ */
26
+ var BackendPostgres = class {
27
+ config;
28
+ namespaceId;
29
+ usePubSub;
30
+ pubsub = null;
31
+ initialized = false;
32
+ runMigrations;
33
+ _knex = null;
34
+ get knex() {
35
+ if (!this._knex) {
36
+ this._knex = knex(this.config);
37
+ this._knex.on("query", (query) => {
38
+ queryLogger.debug("SQL: {query}, Values: {bindings}", {
39
+ query: query.sql,
40
+ bindings: query.bindings
41
+ });
42
+ });
43
+ }
44
+ return this._knex;
45
+ }
46
+ constructor(config, options) {
47
+ this.config = {
48
+ ...config,
49
+ postProcessResponse: (result, _queryContext) => {
50
+ if (result === null || result === void 0) return result;
51
+ if (config?.postProcessResponse) result = config.postProcessResponse(result, _queryContext);
52
+ const camelizeRow = (row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [camelize(key, true), value]));
53
+ if (Array.isArray(result)) return result.map(camelizeRow);
54
+ return camelizeRow(result);
55
+ }
56
+ };
57
+ const { namespaceId, usePubSub, runMigrations } = {
58
+ namespaceId: DEFAULT_NAMESPACE_ID,
59
+ usePubSub: true,
60
+ runMigrations: true,
61
+ ...options
62
+ };
63
+ this.namespaceId = namespaceId;
64
+ this.usePubSub = usePubSub;
65
+ this.runMigrations = runMigrations;
66
+ }
67
+ async initialize() {
68
+ if (this.initialized) return;
69
+ if (this.runMigrations) await migrate(this.config, DEFAULT_SCHEMA);
70
+ this.initialized = true;
71
+ }
72
+ async subscribe(callback) {
73
+ if (!this.initialized) throw new Error("Backend not initialized");
74
+ if (!this.usePubSub) return;
75
+ if (!this.pubsub) this.pubsub = await PostgresPubSub.create(this.knex);
76
+ this.pubsub.listenEvent(DEFAULT_LISTEN_CHANNEL, callback);
77
+ }
78
+ async publish(payload) {
79
+ if (!this.initialized) throw new Error("Backend not initialized");
80
+ if (!this.usePubSub) return;
81
+ await this.knex.raw(payload ? `NOTIFY ${DEFAULT_LISTEN_CHANNEL}, '${payload}'` : `NOTIFY ${DEFAULT_LISTEN_CHANNEL}`);
82
+ }
83
+ async stop() {
84
+ if (!this.initialized) return;
85
+ await this.pubsub?.destroy();
86
+ this.pubsub = null;
87
+ await this.knex.destroy();
88
+ this._knex = null;
89
+ this.initialized = false;
90
+ }
91
+ async createWorkflowRun(params) {
92
+ if (!this.initialized) throw new Error("Backend not initialized");
93
+ logger.info("Creating workflow run: {workflowName}:{version}", {
94
+ workflowName: params.workflowName,
95
+ version: params.version
96
+ });
97
+ const configWithRetryPolicy = {
98
+ ...typeof params.config === "object" && params.config !== null ? params.config : {},
99
+ retryPolicy: params.retryPolicy ?? void 0
100
+ };
101
+ const workflowRun = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").insert({
102
+ namespace_id: this.namespaceId,
103
+ id: crypto.randomUUID(),
104
+ workflow_name: params.workflowName,
105
+ version: params.version,
106
+ status: "pending",
107
+ idempotency_key: params.idempotencyKey,
108
+ config: JSON.stringify(configWithRetryPolicy),
109
+ context: params.context,
110
+ input: params.input,
111
+ attempts: 0,
112
+ available_at: params.availableAt ?? this.knex.fn.now(),
113
+ deadline_at: params.deadlineAt,
114
+ created_at: this.knex.fn.now(),
115
+ updated_at: this.knex.fn.now()
116
+ }).returning("*");
117
+ if (!workflowRun[0]) {
118
+ logger.error("Failed to create workflow run: {params}", { params });
119
+ throw new Error("Failed to create workflow run");
120
+ }
121
+ return workflowRun[0];
122
+ }
123
+ async getWorkflowRun(params) {
124
+ if (!this.initialized) throw new Error("Backend not initialized");
125
+ logger.info("Getting workflow run: {workflowRunId}", { workflowRunId: params.workflowRunId });
126
+ return await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).select("namespace_id", "id", "workflow_name", "version", "status", "idempotency_key", "config", "context", "input", "output", "error", "attempts", "parent_step_attempt_namespace_id", "parent_step_attempt_id", "worker_id", "available_at", "deadline_at", "started_at", "finished_at", "created_at", "updated_at").first() ?? null;
127
+ }
128
+ async listWorkflowRuns(params) {
129
+ if (!this.initialized) throw new Error("Backend not initialized");
130
+ logger.info("Listing workflow runs: {after}, {before}", {
131
+ after: params.after,
132
+ before: params.before
133
+ });
134
+ const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
135
+ const { after, before } = params;
136
+ const order = params.order ?? "asc";
137
+ const reverseOrder = order === "asc" ? "desc" : "asc";
138
+ let cursor = null;
139
+ if (after) cursor = decodeCursor(after);
140
+ else if (before) cursor = decodeCursor(before);
141
+ const rows = await this.buildListWorkflowRunsWhere(params, cursor, order).orderBy("created_at", before ? reverseOrder : order).orderBy("id", before ? reverseOrder : order).limit(limit + 1);
142
+ return this.processPaginationResults(rows, limit, typeof after === "string", typeof before === "string");
143
+ }
144
+ buildListWorkflowRunsWhere(params, cursor, order) {
145
+ const { after } = params;
146
+ const qb = this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId);
147
+ if (cursor) {
148
+ const operator = order === "asc" === !!after ? ">" : "<";
149
+ qb.whereRaw(`("created_at", "id") ${operator} (?, ?)`, [cursor.createdAt.toISOString(), cursor.id]);
150
+ }
151
+ if (params.status && params.status.length > 0) qb.whereIn("status", params.status);
152
+ if (params.workflowName) qb.where("workflow_name", params.workflowName);
153
+ if (params.createdAfter) qb.where("created_at", ">=", params.createdAfter);
154
+ if (params.createdBefore) qb.where("created_at", "<=", params.createdBefore);
155
+ return qb;
156
+ }
157
+ async claimWorkflowRun(params) {
158
+ if (!this.initialized) throw new Error("Backend not initialized");
159
+ logger.info("Claiming workflow run: {workerId}, {leaseDurationMs}", {
160
+ workerId: params.workerId,
161
+ leaseDurationMs: params.leaseDurationMs
162
+ });
163
+ return (await this.knex.with("expired", (qb) => qb.withSchema(DEFAULT_SCHEMA).table("workflow_runs").update({
164
+ status: "failed",
165
+ error: JSON.stringify({ message: "Workflow run deadline exceeded" }),
166
+ worker_id: null,
167
+ available_at: null,
168
+ finished_at: this.knex.raw("NOW()"),
169
+ updated_at: this.knex.raw("NOW()")
170
+ }).where("namespace_id", this.namespaceId).whereIn("status", [
171
+ "pending",
172
+ "running",
173
+ "sleeping"
174
+ ]).whereNotNull("deadline_at").where("deadline_at", "<=", this.knex.raw("NOW()")).returning("id")).with("candidate", (qb) => qb.withSchema(DEFAULT_SCHEMA).select("id").from("workflow_runs").where("namespace_id", this.namespaceId).whereIn("status", [
175
+ "pending",
176
+ "running",
177
+ "sleeping"
178
+ ]).where("available_at", "<=", this.knex.raw("NOW()")).where((qb2) => {
179
+ qb2.whereNull("deadline_at").orWhere("deadline_at", ">", this.knex.raw("NOW()"));
180
+ }).orderByRaw("CASE WHEN status = 'pending' THEN 0 ELSE 1 END").orderBy("available_at", "asc").orderBy("created_at", "asc").limit(1).forUpdate().skipLocked()).withSchema(DEFAULT_SCHEMA).table("workflow_runs as wr").where("wr.namespace_id", this.namespaceId).where("wr.id", this.knex.ref("candidate.id")).update({
181
+ status: "running",
182
+ attempts: this.knex.raw("wr.attempts + 1"),
183
+ worker_id: params.workerId,
184
+ available_at: this.knex.raw(`NOW() + ${params.leaseDurationMs} * INTERVAL '1 millisecond'`),
185
+ started_at: this.knex.raw("COALESCE(wr.started_at, NOW())"),
186
+ updated_at: this.knex.raw("NOW()")
187
+ }).updateFrom("candidate").returning("wr.*"))[0] ?? null;
188
+ }
189
+ async extendWorkflowRunLease(params) {
190
+ if (!this.initialized) throw new Error("Backend not initialized");
191
+ logger.info("Extending workflow run lease: {workflowRunId}, {workerId}, {leaseDurationMs}", {
192
+ workflowRunId: params.workflowRunId,
193
+ workerId: params.workerId,
194
+ leaseDurationMs: params.leaseDurationMs
195
+ });
196
+ const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).where("status", "running").where("worker_id", params.workerId).update({
197
+ available_at: this.knex.raw(`NOW() + ${params.leaseDurationMs} * INTERVAL '1 millisecond'`),
198
+ updated_at: this.knex.fn.now()
199
+ }).returning("*");
200
+ if (!updated) {
201
+ const wr = await this.getWorkflowRun({ workflowRunId: params.workflowRunId });
202
+ if (wr && (wr.status === "paused" || wr.status === "canceled")) throw new Error("Workflow run is paused or canceled");
203
+ logger.error("Failed to extend lease for workflow run: {params}", { params });
204
+ throw new Error("Failed to extend lease for workflow run");
205
+ }
206
+ return updated;
207
+ }
208
+ async sleepWorkflowRun(params) {
209
+ if (!this.initialized) throw new Error("Backend not initialized");
210
+ logger.info("Sleeping workflow run: {workflowRunId}, {workerId}, {availableAt}", {
211
+ workflowRunId: params.workflowRunId,
212
+ workerId: params.workerId,
213
+ availableAt: params.availableAt
214
+ });
215
+ const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).whereNotIn("status", [
216
+ "succeeded",
217
+ "completed",
218
+ "failed",
219
+ "canceled"
220
+ ]).where("worker_id", params.workerId).update({
221
+ status: "sleeping",
222
+ available_at: params.availableAt,
223
+ worker_id: null,
224
+ updated_at: this.knex.fn.now()
225
+ }).returning("*");
226
+ if (!updated) {
227
+ logger.error("Failed to sleep workflow run: {params}", { params });
228
+ throw new Error("Failed to sleep workflow run");
229
+ }
230
+ return updated;
231
+ }
232
+ async completeWorkflowRun(params) {
233
+ if (!this.initialized) throw new Error("Backend not initialized");
234
+ logger.info("Completing workflow run: {workflowRunId}, {workerId}, {output}", {
235
+ workflowRunId: params.workflowRunId,
236
+ workerId: params.workerId,
237
+ output: params.output
238
+ });
239
+ const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).where("status", "running").where("worker_id", params.workerId).update({
240
+ status: "completed",
241
+ output: JSON.stringify(params.output),
242
+ error: null,
243
+ worker_id: params.workerId,
244
+ available_at: null,
245
+ finished_at: this.knex.fn.now(),
246
+ updated_at: this.knex.fn.now()
247
+ }).returning("*");
248
+ if (!updated) {
249
+ logger.error("Failed to complete workflow run: {params}", { params });
250
+ throw new Error("Failed to complete workflow run");
251
+ }
252
+ return updated;
253
+ }
254
+ async failWorkflowRun(params) {
255
+ if (!this.initialized) throw new Error("Backend not initialized");
256
+ const { workflowRunId, error, forceComplete, customDelayMs } = params;
257
+ logger.info("Failing workflow run: {workflowRunId}, {workerId}, {error}", {
258
+ workflowRunId: params.workflowRunId,
259
+ workerId: params.workerId,
260
+ error: params.error
261
+ });
262
+ const workflowRun = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", workflowRunId).first();
263
+ if (!workflowRun) throw new Error("Workflow run not found");
264
+ const savedRetryPolicy = (typeof workflowRun.config === "string" ? JSON.parse(workflowRun.config) : workflowRun.config)?.retryPolicy;
265
+ const { initialIntervalMs, backoffCoefficient, maximumIntervalMs, maxAttempts } = mergeRetryPolicy(savedRetryPolicy);
266
+ const currentAttempts = workflowRun.attempts ?? 0;
267
+ if (forceComplete || currentAttempts >= maxAttempts) {
268
+ const [updated$1] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", workflowRunId).where("status", "running").where("worker_id", params.workerId).update({
269
+ status: "failed",
270
+ available_at: null,
271
+ finished_at: this.knex.fn.now(),
272
+ error: JSON.stringify(error),
273
+ worker_id: null,
274
+ started_at: null,
275
+ updated_at: this.knex.fn.now()
276
+ }).returning("*");
277
+ if (!updated$1) {
278
+ logger.error("Failed to mark workflow run failed: {params}", { params });
279
+ throw new Error("Failed to mark workflow run failed");
280
+ }
281
+ return updated$1;
282
+ }
283
+ const retryIntervalExpr = customDelayMs ? `${customDelayMs} * INTERVAL '1 millisecond'` : `LEAST(${initialIntervalMs} * POWER(${backoffCoefficient}, "attempts" - 1), ${maximumIntervalMs}) * INTERVAL '1 millisecond'`;
284
+ const deadlineExceededCondition = `"deadline_at" IS NOT NULL AND NOW() + (${retryIntervalExpr}) >= "deadline_at"`;
285
+ const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", workflowRunId).where("status", "running").where("worker_id", params.workerId).update({
286
+ status: this.knex.raw(`CASE WHEN ${deadlineExceededCondition} THEN 'failed' ELSE 'pending' END`),
287
+ available_at: this.knex.raw(`CASE WHEN ${deadlineExceededCondition} THEN NULL ELSE NOW() + (${retryIntervalExpr}) END`),
288
+ finished_at: this.knex.raw(`CASE WHEN ${deadlineExceededCondition} THEN NOW() ELSE NULL END`),
289
+ error: JSON.stringify(error),
290
+ worker_id: null,
291
+ started_at: null,
292
+ updated_at: this.knex.fn.now()
293
+ }).returning("*");
294
+ if (!updated) {
295
+ logger.error("Failed to mark workflow run failed: {params}", { params });
296
+ throw new Error("Failed to mark workflow run failed");
297
+ }
298
+ return updated;
299
+ }
300
+ async cancelWorkflowRun(params) {
301
+ if (!this.initialized) throw new Error("Backend not initialized");
302
+ logger.info("Canceling workflow run: {workflowRunId}", { workflowRunId: params.workflowRunId });
303
+ const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).whereIn("status", [
304
+ "pending",
305
+ "running",
306
+ "sleeping",
307
+ "paused"
308
+ ]).update({
309
+ status: "canceled",
310
+ worker_id: null,
311
+ available_at: null,
312
+ finished_at: this.knex.fn.now(),
313
+ updated_at: this.knex.fn.now()
314
+ }).returning("*");
315
+ if (!updated) {
316
+ const existing = await this.getWorkflowRun({ workflowRunId: params.workflowRunId });
317
+ if (!existing) throw new Error(`Workflow run ${params.workflowRunId} does not exist`);
318
+ if (existing.status === "canceled") return existing;
319
+ if ([
320
+ "succeeded",
321
+ "completed",
322
+ "failed"
323
+ ].includes(existing.status)) {
324
+ logger.error("Cannot cancel workflow run: {params} with status {status}", {
325
+ params,
326
+ status: existing.status
327
+ });
328
+ throw new Error(`Cannot cancel workflow run ${params.workflowRunId} with status ${existing.status}`);
329
+ }
330
+ logger.error("Failed to cancel workflow run: {params}", { params });
331
+ throw new Error("Failed to cancel workflow run");
332
+ }
333
+ return updated;
334
+ }
335
+ async pauseWorkflowRun(params) {
336
+ if (!this.initialized) throw new Error("Backend not initialized");
337
+ logger.info("Pausing workflow run: {workflowRunId}", { workflowRunId: params.workflowRunId });
338
+ const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).whereIn("status", [
339
+ "pending",
340
+ "running",
341
+ "sleeping"
342
+ ]).update({
343
+ status: "paused",
344
+ worker_id: null,
345
+ available_at: null,
346
+ updated_at: this.knex.fn.now()
347
+ }).returning("*");
348
+ if (!updated) {
349
+ const existing = await this.getWorkflowRun({ workflowRunId: params.workflowRunId });
350
+ if (!existing) throw new Error(`Workflow run ${params.workflowRunId} does not exist`);
351
+ if (existing.status === "paused") return existing;
352
+ if ([
353
+ "succeeded",
354
+ "completed",
355
+ "failed",
356
+ "canceled"
357
+ ].includes(existing.status)) {
358
+ logger.error("Cannot pause workflow run: {params} with status {status}", {
359
+ params,
360
+ status: existing.status
361
+ });
362
+ throw new Error(`Cannot pause workflow run ${params.workflowRunId} with status ${existing.status}`);
363
+ }
364
+ logger.error("Failed to pause workflow run: {params}", { params });
365
+ throw new Error("Failed to pause workflow run");
366
+ }
367
+ return updated;
368
+ }
369
+ async resumeWorkflowRun(params) {
370
+ if (!this.initialized) throw new Error("Backend not initialized");
371
+ logger.info("Resuming workflow run: {workflowRunId}", { workflowRunId: params.workflowRunId });
372
+ const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).where("status", "paused").update({
373
+ status: "pending",
374
+ available_at: this.knex.fn.now(),
375
+ updated_at: this.knex.fn.now()
376
+ }).returning("*");
377
+ if (!updated) {
378
+ const existing = await this.getWorkflowRun({ workflowRunId: params.workflowRunId });
379
+ if (!existing) throw new Error(`Workflow run ${params.workflowRunId} does not exist`);
380
+ if (existing.status === "pending" || existing.status === "running") return existing;
381
+ if ([
382
+ "succeeded",
383
+ "completed",
384
+ "failed",
385
+ "canceled"
386
+ ].includes(existing.status)) {
387
+ logger.error("Cannot resume workflow run: {params} with status {status}", {
388
+ params,
389
+ status: existing.status
390
+ });
391
+ throw new Error(`Cannot resume workflow run ${params.workflowRunId} with status ${existing.status}`);
392
+ }
393
+ logger.error("Failed to resume workflow run: {params}", { params });
394
+ throw new Error("Failed to resume workflow run");
395
+ }
396
+ return updated;
397
+ }
398
+ async createStepAttempt(params) {
399
+ if (!this.initialized) throw new Error("Backend not initialized");
400
+ logger.info("Creating step attempt: {workflowRunId}, {stepName}, {kind}", {
401
+ workflowRunId: params.workflowRunId,
402
+ stepName: params.stepName,
403
+ kind: params.kind
404
+ });
405
+ const [stepAttempt] = await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts").insert({
406
+ namespace_id: this.namespaceId,
407
+ id: crypto.randomUUID(),
408
+ workflow_run_id: params.workflowRunId,
409
+ step_name: params.stepName,
410
+ kind: params.kind,
411
+ status: "running",
412
+ config: JSON.stringify(params.config),
413
+ context: JSON.stringify(params.context),
414
+ started_at: this.knex.fn.now(),
415
+ created_at: this.knex.raw("date_trunc('milliseconds', NOW())"),
416
+ updated_at: this.knex.fn.now()
417
+ }).returning("*");
418
+ if (!stepAttempt) {
419
+ logger.error("Failed to create step attempt: {params}", { params });
420
+ throw new Error("Failed to create step attempt");
421
+ }
422
+ return stepAttempt;
423
+ }
424
+ async getStepAttempt(params) {
425
+ if (!this.initialized) throw new Error("Backend not initialized");
426
+ logger.info("Getting step attempt: {stepAttemptId}", { stepAttemptId: params.stepAttemptId });
427
+ return await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts").where("namespace_id", this.namespaceId).where("id", params.stepAttemptId).first() ?? null;
428
+ }
429
+ async listStepAttempts(params) {
430
+ if (!this.initialized) throw new Error("Backend not initialized");
431
+ logger.info("Listing step attempts: {workflowRunId}, {after}, {before}", {
432
+ workflowRunId: params.workflowRunId,
433
+ after: params.after,
434
+ before: params.before
435
+ });
436
+ const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
437
+ const { after, before } = params;
438
+ const order = params.order ?? "asc";
439
+ const reverseOrder = order === "asc" ? "desc" : "asc";
440
+ let cursor = null;
441
+ if (after) cursor = decodeCursor(after);
442
+ else if (before) cursor = decodeCursor(before);
443
+ const rows = await this.buildListStepAttemptsWhere(params, cursor, order).orderBy("created_at", before ? reverseOrder : order).orderBy("id", before ? reverseOrder : order).limit(limit + 1);
444
+ return this.processPaginationResults(rows, limit, typeof after === "string", typeof before === "string");
445
+ }
446
+ buildListStepAttemptsWhere(params, cursor, order) {
447
+ const { after } = params;
448
+ const qb = this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts").where("namespace_id", this.namespaceId).where("workflow_run_id", params.workflowRunId);
449
+ if (cursor) {
450
+ const operator = order === "asc" === !!after ? ">" : "<";
451
+ return qb.whereRaw(`("created_at", "id") ${operator} (?, ?)`, [cursor.createdAt.toISOString(), cursor.id]);
452
+ }
453
+ return qb;
454
+ }
455
+ processPaginationResults(rows, limit, hasAfter, hasBefore) {
456
+ const data = rows;
457
+ let hasNext = false;
458
+ let hasPrev = false;
459
+ if (hasBefore) {
460
+ data.reverse();
461
+ if (data.length > limit) {
462
+ hasPrev = true;
463
+ data.shift();
464
+ }
465
+ hasNext = true;
466
+ } else {
467
+ if (data.length > limit) {
468
+ hasNext = true;
469
+ data.pop();
470
+ }
471
+ if (hasAfter) hasPrev = true;
472
+ }
473
+ const lastItem = data.at(-1);
474
+ const nextCursor = hasNext && lastItem ? encodeCursor(lastItem) : null;
475
+ const firstItem = data[0];
476
+ return {
477
+ data,
478
+ pagination: {
479
+ next: nextCursor,
480
+ prev: hasPrev && firstItem ? encodeCursor(firstItem) : null
481
+ }
482
+ };
483
+ }
484
+ async completeStepAttempt(params) {
485
+ if (!this.initialized) throw new Error("Backend not initialized");
486
+ logger.info("Marking step attempt as completed: {workflowRunId}, {stepAttemptId}, {workerId}", {
487
+ workflowRunId: params.workflowRunId,
488
+ stepAttemptId: params.stepAttemptId,
489
+ workerId: params.workerId
490
+ });
491
+ const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts as sa").update({
492
+ status: "completed",
493
+ output: JSON.stringify(params.output),
494
+ error: null,
495
+ finished_at: this.knex.fn.now(),
496
+ updated_at: this.knex.fn.now()
497
+ }).updateFrom(`${DEFAULT_SCHEMA}.workflow_runs as wr`).where("sa.namespace_id", this.namespaceId).where("sa.workflow_run_id", params.workflowRunId).where("sa.id", params.stepAttemptId).where("sa.status", "running").where("wr.namespace_id", this.knex.ref("sa.namespace_id")).where("wr.id", this.knex.ref("sa.workflow_run_id")).where("wr.status", "running").where("wr.worker_id", params.workerId).returning("sa.*");
498
+ if (!updated) return this.handleStepAttemptUpdateMiss("completed", params);
499
+ return updated;
500
+ }
501
+ async failStepAttempt(params) {
502
+ if (!this.initialized) throw new Error("Backend not initialized");
503
+ logger.info("Marking step attempt as failed: {workflowRunId}, {stepAttemptId}, {workerId}", {
504
+ workflowRunId: params.workflowRunId,
505
+ stepAttemptId: params.stepAttemptId,
506
+ workerId: params.workerId
507
+ });
508
+ logger.info("Error: {error.message}", { error: params.error.message });
509
+ const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts as sa").update({
510
+ status: "failed",
511
+ output: null,
512
+ error: JSON.stringify(params.error),
513
+ finished_at: this.knex.fn.now(),
514
+ updated_at: this.knex.fn.now()
515
+ }).updateFrom(`${DEFAULT_SCHEMA}.workflow_runs as wr`).where("sa.namespace_id", this.namespaceId).where("sa.workflow_run_id", params.workflowRunId).where("sa.id", params.stepAttemptId).where("sa.status", "running").where("wr.namespace_id", this.knex.ref("sa.namespace_id")).where("wr.id", this.knex.ref("sa.workflow_run_id")).where("wr.status", "running").where("wr.worker_id", params.workerId).returning("sa.*");
516
+ if (!updated) return this.handleStepAttemptUpdateMiss("failed", params);
517
+ return updated;
518
+ }
519
+ /**
520
+ * completeStepAttempt/failStepAttempt에서 UPDATE가 0건일 때,
521
+ * 외부 상태 변경(pause/cancel)에 의한 것인지 판단합니다.
522
+ * - 외부 상태 변경이면 해당 step의 상태도 워크플로우와 동일하게 맞추고 null을 반환합니다.
523
+ * - 그 외에는 예상하지 못한 상황이므로 에러를 throw합니다.
524
+ */
525
+ async handleStepAttemptUpdateMiss(method, params) {
526
+ const wr = await this.getWorkflowRun({ workflowRunId: params.workflowRunId });
527
+ if (wr && (wr.status === "paused" || wr.status === "canceled")) {
528
+ await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts").where("namespace_id", this.namespaceId).where("id", params.stepAttemptId).whereIn("status", ["running", "paused"]).update({
529
+ status: wr.status,
530
+ updated_at: this.knex.fn.now()
531
+ });
532
+ return null;
533
+ }
534
+ logger.error("Failed to mark step attempt {method}: {params}", {
535
+ method,
536
+ params
537
+ });
538
+ throw new Error(`Failed to mark step attempt ${method}`);
539
+ }
540
+ };
543
541
  function encodeCursor(item) {
544
- const encoded = Buffer.from(JSON.stringify({
545
- createdAt: item.createdAt.toISOString(),
546
- id: item.id
547
- })).toString("base64");
548
- return encoded;
542
+ return Buffer.from(JSON.stringify({
543
+ createdAt: item.createdAt.toISOString(),
544
+ id: item.id
545
+ })).toString("base64");
549
546
  }
550
- export function decodeCursor(cursor) {
551
- const decoded = Buffer.from(cursor, "base64").toString("utf8");
552
- const parsed = JSON.parse(decoded);
553
- return {
554
- createdAt: new Date(parsed.createdAt),
555
- id: parsed.id
556
- };
547
+ function decodeCursor(cursor) {
548
+ const decoded = Buffer.from(cursor, "base64").toString("utf8");
549
+ const parsed = JSON.parse(decoded);
550
+ return {
551
+ createdAt: new Date(parsed.createdAt),
552
+ id: parsed.id
553
+ };
557
554
  }
558
555
 
556
+ //#endregion
557
+ export { BackendPostgres };
559
558
  //# sourceMappingURL=backend.js.map