@sonamu-kit/tasks 0.2.0 → 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 -107
  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 +219 -213
  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 +37 -31
  30. package/dist/core/retry.d.ts.map +1 -1
  31. package/dist/core/retry.js +44 -51
  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 -577
  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 -59
  70. package/dist/execution.d.ts.map +1 -1
  71. package/dist/execution.js +175 -188
  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 -13
  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 -199
  84. package/dist/worker.js.map +1 -1
  85. package/dist/workflow.d.ts +26 -30
  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 +17 -19
  91. package/src/backend.ts +25 -9
  92. package/src/chaos.test.ts +3 -1
  93. package/src/client.test.ts +2 -0
  94. package/src/client.ts +30 -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 +3 -2
  103. package/src/core/retry.ts +1 -1
  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 +44 -40
  111. package/src/database/backend.ts +207 -25
  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 +2 -0
  121. package/src/execution.ts +49 -10
  122. package/src/internal.ts +15 -15
  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 +30 -9
  129. package/src/workflow.test.ts +1 -0
  130. package/src/workflow.ts +3 -3
  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 -198
  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 -1280
  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 -662
  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,591 +1,558 @@
1
- import { getLogger } from "@logtape/logtape";
2
- import { camelize } from "inflection";
3
- import knex from "knex";
4
- import { DEFAULT_NAMESPACE_ID } from "../backend.js";
5
1
  import { mergeRetryPolicy } from "../core/retry.js";
2
+ import { DEFAULT_NAMESPACE_ID } from "../backend.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
- // config에 retryPolicy를 포함시킵니다.
120
- const configWithRetryPolicy = {
121
- ...typeof params.config === "object" && params.config !== null ? params.config : {},
122
- retryPolicy: params.retryPolicy ?? undefined
123
- };
124
- const qb = this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").insert({
125
- namespace_id: this.namespaceId,
126
- id: crypto.randomUUID(),
127
- workflow_name: params.workflowName,
128
- version: params.version,
129
- status: "pending",
130
- idempotency_key: params.idempotencyKey,
131
- config: JSON.stringify(configWithRetryPolicy),
132
- context: params.context,
133
- input: params.input,
134
- attempts: 0,
135
- available_at: params.availableAt ?? this.knex.fn.now(),
136
- deadline_at: params.deadlineAt,
137
- created_at: this.knex.fn.now(),
138
- updated_at: this.knex.fn.now()
139
- }).returning("*");
140
- const workflowRun = await qb;
141
- if (!workflowRun[0]) {
142
- logger.error("Failed to create workflow run: {params}", {
143
- params
144
- });
145
- throw new Error("Failed to create workflow run");
146
- }
147
- return workflowRun[0];
148
- }
149
- async getWorkflowRun(params) {
150
- if (!this.initialized) {
151
- throw new Error("Backend not initialized");
152
- }
153
- logger.info("Getting workflow run: {workflowRunId}", {
154
- workflowRunId: params.workflowRunId
155
- });
156
- 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();
157
- return workflowRun ?? null;
158
- }
159
- async listWorkflowRuns(params) {
160
- if (!this.initialized) {
161
- throw new Error("Backend not initialized");
162
- }
163
- logger.info("Listing workflow runs: {after}, {before}", {
164
- after: params.after,
165
- before: params.before
166
- });
167
- const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
168
- const { after, before } = params;
169
- let cursor = null;
170
- if (after) {
171
- cursor = decodeCursor(after);
172
- } else if (before) {
173
- cursor = decodeCursor(before);
174
- }
175
- const qb = this.buildListWorkflowRunsWhere(params, cursor);
176
- const rows = await qb.orderBy("created_at", before ? "desc" : "asc").orderBy("id", before ? "desc" : "asc").limit(limit + 1);
177
- return this.processPaginationResults(rows, limit, typeof after === "string", typeof before === "string");
178
- }
179
- buildListWorkflowRunsWhere(params, cursor) {
180
- const { after } = params;
181
- const qb = this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId);
182
- if (cursor) {
183
- const operator = after ? ">" : "<";
184
- return qb.whereRaw(`("created_at", "id") ${operator} (?, ?)`, [
185
- cursor.createdAt.toISOString(),
186
- cursor.id
187
- ]);
188
- }
189
- return qb;
190
- }
191
- async claimWorkflowRun(params) {
192
- if (!this.initialized) {
193
- throw new Error("Backend not initialized");
194
- }
195
- logger.info("Claiming workflow run: {workerId}, {leaseDurationMs}", {
196
- workerId: params.workerId,
197
- leaseDurationMs: params.leaseDurationMs
198
- });
199
- const claimed = await this.knex.with("expired", (qb)=>qb.withSchema(DEFAULT_SCHEMA).table("workflow_runs").update({
200
- status: "failed",
201
- error: JSON.stringify({
202
- message: "Workflow run deadline exceeded"
203
- }),
204
- worker_id: null,
205
- available_at: null,
206
- finished_at: this.knex.raw("NOW()"),
207
- updated_at: this.knex.raw("NOW()")
208
- }).where("namespace_id", this.namespaceId).whereIn("status", [
209
- "pending",
210
- "running",
211
- "sleeping"
212
- ]).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", [
213
- "pending",
214
- "running",
215
- "sleeping"
216
- ]).where("available_at", "<=", this.knex.raw("NOW()")).where((qb2)=>{
217
- qb2.whereNull("deadline_at").orWhere("deadline_at", ">", this.knex.raw("NOW()"));
218
- }).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({
219
- status: "running",
220
- attempts: this.knex.raw("wr.attempts + 1"),
221
- worker_id: params.workerId,
222
- available_at: this.knex.raw(`NOW() + ${params.leaseDurationMs} * INTERVAL '1 millisecond'`),
223
- started_at: this.knex.raw("COALESCE(wr.started_at, NOW())"),
224
- updated_at: this.knex.raw("NOW()")
225
- }).updateFrom("candidate").returning("wr.*");
226
- return claimed[0] ?? null;
227
- }
228
- async extendWorkflowRunLease(params) {
229
- if (!this.initialized) {
230
- throw new Error("Backend not initialized");
231
- }
232
- logger.info("Extending workflow run lease: {workflowRunId}, {workerId}, {leaseDurationMs}", {
233
- workflowRunId: params.workflowRunId,
234
- workerId: params.workerId,
235
- leaseDurationMs: params.leaseDurationMs
236
- });
237
- 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({
238
- available_at: this.knex.raw(`NOW() + ${params.leaseDurationMs} * INTERVAL '1 millisecond'`),
239
- updated_at: this.knex.fn.now()
240
- }).returning("*");
241
- if (!updated) {
242
- logger.error("Failed to extend lease for workflow run: {params}", {
243
- params
244
- });
245
- throw new Error("Failed to extend lease for workflow run");
246
- }
247
- return updated;
248
- }
249
- async sleepWorkflowRun(params) {
250
- if (!this.initialized) {
251
- throw new Error("Backend not initialized");
252
- }
253
- logger.info("Sleeping workflow run: {workflowRunId}, {workerId}, {availableAt}", {
254
- workflowRunId: params.workflowRunId,
255
- workerId: params.workerId,
256
- availableAt: params.availableAt
257
- });
258
- // 'succeeded' status is deprecated
259
- const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).whereNotIn("status", [
260
- "succeeded",
261
- "completed",
262
- "failed",
263
- "canceled"
264
- ]).where("worker_id", params.workerId).update({
265
- status: "sleeping",
266
- available_at: params.availableAt,
267
- worker_id: null,
268
- updated_at: this.knex.fn.now()
269
- }).returning("*");
270
- if (!updated) {
271
- logger.error("Failed to sleep workflow run: {params}", {
272
- params
273
- });
274
- throw new Error("Failed to sleep workflow run");
275
- }
276
- return updated;
277
- }
278
- async completeWorkflowRun(params) {
279
- if (!this.initialized) {
280
- throw new Error("Backend not initialized");
281
- }
282
- logger.info("Completing workflow run: {workflowRunId}, {workerId}, {output}", {
283
- workflowRunId: params.workflowRunId,
284
- workerId: params.workerId,
285
- output: params.output
286
- });
287
- 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({
288
- status: "completed",
289
- output: JSON.stringify(params.output),
290
- error: null,
291
- worker_id: params.workerId,
292
- available_at: null,
293
- finished_at: this.knex.fn.now(),
294
- updated_at: this.knex.fn.now()
295
- }).returning("*");
296
- if (!updated) {
297
- logger.error("Failed to complete workflow run: {params}", {
298
- params
299
- });
300
- throw new Error("Failed to complete workflow run");
301
- }
302
- return updated;
303
- }
304
- async failWorkflowRun(params) {
305
- if (!this.initialized) {
306
- throw new Error("Backend not initialized");
307
- }
308
- const { workflowRunId, error, forceComplete, customDelayMs } = params;
309
- logger.info("Failing workflow run: {workflowRunId}, {workerId}, {error}", {
310
- workflowRunId: params.workflowRunId,
311
- workerId: params.workerId,
312
- error: params.error
313
- });
314
- const workflowRun = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", workflowRunId).first();
315
- if (!workflowRun) {
316
- throw new Error("Workflow run not found");
317
- }
318
- const config = typeof workflowRun.config === "string" ? JSON.parse(workflowRun.config) : workflowRun.config;
319
- const savedRetryPolicy = config?.retryPolicy;
320
- const retryPolicy = mergeRetryPolicy(savedRetryPolicy);
321
- const { initialIntervalMs, backoffCoefficient, maximumIntervalMs, maxAttempts } = retryPolicy;
322
- const currentAttempts = workflowRun.attempts ?? 0;
323
- const shouldForceComplete = forceComplete || currentAttempts >= maxAttempts;
324
- if (shouldForceComplete) {
325
- 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({
326
- status: "failed",
327
- available_at: null,
328
- finished_at: this.knex.fn.now(),
329
- error: JSON.stringify(error),
330
- worker_id: null,
331
- started_at: null,
332
- updated_at: this.knex.fn.now()
333
- }).returning("*");
334
- if (!updated) {
335
- logger.error("Failed to mark workflow run failed: {params}", {
336
- params
337
- });
338
- throw new Error("Failed to mark workflow run failed");
339
- }
340
- return updated;
341
- }
342
- // this beefy query updates a workflow's status, available_at, and
343
- // finished_at based on the workflow's deadline and retry policy
344
- //
345
- // if the next retry would exceed the deadline, the run is marked as
346
- // 'failed' and finalized, otherwise, the run is rescheduled with an updated
347
- // 'available_at' timestamp for the next retry
348
- const retryIntervalExpr = customDelayMs ? `${customDelayMs} * INTERVAL '1 millisecond'` : `LEAST(${initialIntervalMs} * POWER(${backoffCoefficient}, "attempts" - 1), ${maximumIntervalMs}) * INTERVAL '1 millisecond'`;
349
- const deadlineExceededCondition = `"deadline_at" IS NOT NULL AND NOW() + (${retryIntervalExpr}) >= "deadline_at"`;
350
- 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({
351
- status: this.knex.raw(`CASE WHEN ${deadlineExceededCondition} THEN 'failed' ELSE 'pending' END`),
352
- available_at: this.knex.raw(`CASE WHEN ${deadlineExceededCondition} THEN NULL ELSE NOW() + (${retryIntervalExpr}) END`),
353
- finished_at: this.knex.raw(`CASE WHEN ${deadlineExceededCondition} THEN NOW() ELSE NULL END`),
354
- error: JSON.stringify(error),
355
- worker_id: null,
356
- started_at: null,
357
- updated_at: this.knex.fn.now()
358
- }).returning("*");
359
- if (!updated) {
360
- logger.error("Failed to mark workflow run failed: {params}", {
361
- params
362
- });
363
- throw new Error("Failed to mark workflow run failed");
364
- }
365
- return updated;
366
- }
367
- async cancelWorkflowRun(params) {
368
- if (!this.initialized) {
369
- throw new Error("Backend not initialized");
370
- }
371
- logger.info("Canceling workflow run: {workflowRunId}", {
372
- workflowRunId: params.workflowRunId
373
- });
374
- const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("workflow_runs").where("namespace_id", this.namespaceId).where("id", params.workflowRunId).whereIn("status", [
375
- "pending",
376
- "running",
377
- "sleeping"
378
- ]).update({
379
- status: "canceled",
380
- worker_id: null,
381
- available_at: null,
382
- finished_at: this.knex.fn.now(),
383
- updated_at: this.knex.fn.now()
384
- }).returning("*");
385
- if (!updated) {
386
- // workflow may already be in a terminal state
387
- const existing = await this.getWorkflowRun({
388
- workflowRunId: params.workflowRunId
389
- });
390
- if (!existing) {
391
- throw new Error(`Workflow run ${params.workflowRunId} does not exist`);
392
- }
393
- // if already canceled, just return it
394
- if (existing.status === "canceled") {
395
- return existing;
396
- }
397
- // throw error for completed/failed workflows
398
- // 'succeeded' status is deprecated
399
- if ([
400
- "succeeded",
401
- "completed",
402
- "failed"
403
- ].includes(existing.status)) {
404
- logger.error("Cannot cancel workflow run: {params} with status {status}", {
405
- params,
406
- status: existing.status
407
- });
408
- throw new Error(`Cannot cancel workflow run ${params.workflowRunId} with status ${existing.status}`);
409
- }
410
- logger.error("Failed to cancel workflow run: {params}", {
411
- params
412
- });
413
- throw new Error("Failed to cancel workflow run");
414
- }
415
- return updated;
416
- }
417
- async createStepAttempt(params) {
418
- if (!this.initialized) {
419
- throw new Error("Backend not initialized");
420
- }
421
- logger.info("Creating step attempt: {workflowRunId}, {stepName}, {kind}", {
422
- workflowRunId: params.workflowRunId,
423
- stepName: params.stepName,
424
- kind: params.kind
425
- });
426
- const [stepAttempt] = await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts").insert({
427
- namespace_id: this.namespaceId,
428
- id: crypto.randomUUID(),
429
- workflow_run_id: params.workflowRunId,
430
- step_name: params.stepName,
431
- kind: params.kind,
432
- status: "running",
433
- config: JSON.stringify(params.config),
434
- context: JSON.stringify(params.context),
435
- started_at: this.knex.fn.now(),
436
- created_at: this.knex.raw("date_trunc('milliseconds', NOW())"),
437
- updated_at: this.knex.fn.now()
438
- }).returning("*");
439
- if (!stepAttempt) {
440
- logger.error("Failed to create step attempt: {params}", {
441
- params
442
- });
443
- throw new Error("Failed to create step attempt");
444
- }
445
- return stepAttempt;
446
- }
447
- async getStepAttempt(params) {
448
- if (!this.initialized) {
449
- throw new Error("Backend not initialized");
450
- }
451
- logger.info("Getting step attempt: {stepAttemptId}", {
452
- stepAttemptId: params.stepAttemptId
453
- });
454
- const stepAttempt = await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts").where("namespace_id", this.namespaceId).where("id", params.stepAttemptId).first();
455
- return stepAttempt ?? null;
456
- }
457
- async listStepAttempts(params) {
458
- if (!this.initialized) {
459
- throw new Error("Backend not initialized");
460
- }
461
- logger.info("Listing step attempts: {workflowRunId}, {after}, {before}", {
462
- workflowRunId: params.workflowRunId,
463
- after: params.after,
464
- before: params.before
465
- });
466
- const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
467
- const { after, before } = params;
468
- let cursor = null;
469
- if (after) {
470
- cursor = decodeCursor(after);
471
- } else if (before) {
472
- cursor = decodeCursor(before);
473
- }
474
- const qb = this.buildListStepAttemptsWhere(params, cursor);
475
- const rows = await qb.orderBy("created_at", before ? "desc" : "asc").orderBy("id", before ? "desc" : "asc").limit(limit + 1);
476
- return this.processPaginationResults(rows, limit, typeof after === "string", typeof before === "string");
477
- }
478
- buildListStepAttemptsWhere(params, cursor) {
479
- const { after } = params;
480
- const qb = this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts").where("namespace_id", this.namespaceId).where("workflow_run_id", params.workflowRunId);
481
- if (cursor) {
482
- const operator = after ? ">" : "<";
483
- return qb.whereRaw(`("created_at", "id") ${operator} (?, ?)`, [
484
- cursor.createdAt.toISOString(),
485
- cursor.id
486
- ]);
487
- }
488
- return qb;
489
- }
490
- processPaginationResults(rows, limit, hasAfter, hasBefore) {
491
- const data = rows;
492
- let hasNext = false;
493
- let hasPrev = false;
494
- if (hasBefore) {
495
- data.reverse();
496
- if (data.length > limit) {
497
- hasPrev = true;
498
- data.shift();
499
- }
500
- hasNext = true;
501
- } else {
502
- if (data.length > limit) {
503
- hasNext = true;
504
- data.pop();
505
- }
506
- if (hasAfter) {
507
- hasPrev = true;
508
- }
509
- }
510
- const lastItem = data.at(-1);
511
- const nextCursor = hasNext && lastItem ? encodeCursor(lastItem) : null;
512
- const firstItem = data[0];
513
- const prevCursor = hasPrev && firstItem ? encodeCursor(firstItem) : null;
514
- return {
515
- data,
516
- pagination: {
517
- next: nextCursor,
518
- prev: prevCursor
519
- }
520
- };
521
- }
522
- // NOTE: 실제 서비스에서 이게 안 되는 것 같은데, 쿼리 등을 체크할 필요가 있음.
523
- async completeStepAttempt(params) {
524
- if (!this.initialized) {
525
- throw new Error("Backend not initialized");
526
- }
527
- logger.info("Marking step attempt as completed: {workflowRunId}, {stepAttemptId}, {workerId}", {
528
- workflowRunId: params.workflowRunId,
529
- stepAttemptId: params.stepAttemptId,
530
- workerId: params.workerId
531
- });
532
- const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts as sa").update({
533
- status: "completed",
534
- output: JSON.stringify(params.output),
535
- error: null,
536
- finished_at: this.knex.fn.now(),
537
- updated_at: this.knex.fn.now()
538
- }).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.*");
539
- if (!updated) {
540
- logger.error("Failed to mark step attempt completed: {params}", {
541
- params
542
- });
543
- throw new Error("Failed to mark step attempt completed");
544
- }
545
- return updated;
546
- }
547
- async failStepAttempt(params) {
548
- if (!this.initialized) {
549
- throw new Error("Backend not initialized");
550
- }
551
- logger.info("Marking step attempt as failed: {workflowRunId}, {stepAttemptId}, {workerId}", {
552
- workflowRunId: params.workflowRunId,
553
- stepAttemptId: params.stepAttemptId,
554
- workerId: params.workerId
555
- });
556
- logger.info("Error: {error.message}", {
557
- error: params.error.message
558
- });
559
- const [updated] = await this.knex.withSchema(DEFAULT_SCHEMA).table("step_attempts as sa").update({
560
- status: "failed",
561
- output: null,
562
- error: JSON.stringify(params.error),
563
- finished_at: this.knex.fn.now(),
564
- updated_at: this.knex.fn.now()
565
- }).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.*");
566
- if (!updated) {
567
- logger.error("Failed to mark step attempt failed: {params}", {
568
- params
569
- });
570
- throw new Error("Failed to mark step attempt failed");
571
- }
572
- return updated;
573
- }
574
- }
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
+ };
575
541
  function encodeCursor(item) {
576
- const encoded = Buffer.from(JSON.stringify({
577
- createdAt: item.createdAt.toISOString(),
578
- id: item.id
579
- })).toString("base64");
580
- return encoded;
542
+ return Buffer.from(JSON.stringify({
543
+ createdAt: item.createdAt.toISOString(),
544
+ id: item.id
545
+ })).toString("base64");
581
546
  }
582
- export function decodeCursor(cursor) {
583
- const decoded = Buffer.from(cursor, "base64").toString("utf8");
584
- const parsed = JSON.parse(decoded);
585
- return {
586
- createdAt: new Date(parsed.createdAt),
587
- id: parsed.id
588
- };
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
+ };
589
554
  }
590
555
 
556
+ //#endregion
557
+ export { BackendPostgres };
591
558
  //# sourceMappingURL=backend.js.map