@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.
- package/.oxlintrc.json +3 -0
- package/AGENTS.md +21 -0
- package/dist/backend.d.ts +126 -103
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +4 -1
- package/dist/backend.js.map +1 -1
- package/dist/client.d.ts +145 -132
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +220 -212
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +15 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +22 -17
- package/dist/config.js.map +1 -1
- package/dist/core/duration.d.ts +5 -4
- package/dist/core/duration.d.ts.map +1 -1
- package/dist/core/duration.js +54 -59
- package/dist/core/duration.js.map +1 -1
- package/dist/core/error.d.ts +10 -7
- package/dist/core/error.d.ts.map +1 -1
- package/dist/core/error.js +21 -21
- package/dist/core/error.js.map +1 -1
- package/dist/core/json.d.ts +8 -3
- package/dist/core/json.d.ts.map +1 -1
- package/dist/core/result.d.ts +10 -14
- package/dist/core/result.d.ts.map +1 -1
- package/dist/core/result.js +21 -16
- package/dist/core/result.js.map +1 -1
- package/dist/core/retry.d.ts +42 -20
- package/dist/core/retry.d.ts.map +1 -1
- package/dist/core/retry.js +49 -20
- package/dist/core/retry.js.map +1 -1
- package/dist/core/schema.d.ts +57 -53
- package/dist/core/schema.d.ts.map +1 -1
- package/dist/core/step.d.ts +28 -78
- package/dist/core/step.d.ts.map +1 -1
- package/dist/core/step.js +53 -63
- package/dist/core/step.js.map +1 -1
- package/dist/core/workflow.d.ts +33 -61
- package/dist/core/workflow.d.ts.map +1 -1
- package/dist/core/workflow.js +31 -41
- package/dist/core/workflow.js.map +1 -1
- package/dist/database/backend.d.ts +53 -46
- package/dist/database/backend.d.ts.map +1 -1
- package/dist/database/backend.js +544 -545
- package/dist/database/backend.js.map +1 -1
- package/dist/database/base.js +48 -25
- package/dist/database/base.js.map +1 -1
- package/dist/database/migrations/20251212000000_0_init.d.ts +10 -0
- package/dist/database/migrations/20251212000000_0_init.d.ts.map +1 -0
- package/dist/database/migrations/20251212000000_0_init.js +8 -4
- package/dist/database/migrations/20251212000000_0_init.js.map +1 -1
- package/dist/database/migrations/20251212000000_1_tables.d.ts +10 -0
- package/dist/database/migrations/20251212000000_1_tables.d.ts.map +1 -0
- package/dist/database/migrations/20251212000000_1_tables.js +81 -83
- package/dist/database/migrations/20251212000000_1_tables.js.map +1 -1
- package/dist/database/migrations/20251212000000_2_fk.d.ts +10 -0
- package/dist/database/migrations/20251212000000_2_fk.d.ts.map +1 -0
- package/dist/database/migrations/20251212000000_2_fk.js +20 -43
- package/dist/database/migrations/20251212000000_2_fk.js.map +1 -1
- package/dist/database/migrations/20251212000000_3_indexes.d.ts +10 -0
- package/dist/database/migrations/20251212000000_3_indexes.d.ts.map +1 -0
- package/dist/database/migrations/20251212000000_3_indexes.js +88 -102
- package/dist/database/migrations/20251212000000_3_indexes.js.map +1 -1
- package/dist/database/pubsub.d.ts +7 -16
- package/dist/database/pubsub.d.ts.map +1 -1
- package/dist/database/pubsub.js +75 -73
- package/dist/database/pubsub.js.map +1 -1
- package/dist/execution.d.ts +20 -57
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +175 -174
- package/dist/execution.js.map +1 -1
- package/dist/index.d.ts +5 -8
- package/dist/index.js +5 -5
- package/dist/internal.d.ts +12 -12
- package/dist/internal.js +4 -4
- package/dist/registry.d.ts +33 -27
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +58 -49
- package/dist/registry.js.map +1 -1
- package/dist/worker.d.ts +57 -50
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +194 -198
- package/dist/worker.js.map +1 -1
- package/dist/workflow.d.ts +26 -27
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +20 -15
- package/dist/workflow.js.map +1 -1
- package/nodemon.json +1 -1
- package/package.json +18 -20
- package/src/backend.ts +28 -8
- package/src/chaos.test.ts +3 -1
- package/src/client.test.ts +2 -0
- package/src/client.ts +32 -8
- package/src/config.test.ts +1 -0
- package/src/config.ts +3 -2
- package/src/core/duration.test.ts +2 -1
- package/src/core/duration.ts +1 -1
- package/src/core/error.test.ts +1 -0
- package/src/core/error.ts +1 -1
- package/src/core/result.test.ts +1 -0
- package/src/core/retry.test.ts +181 -11
- package/src/core/retry.ts +95 -19
- package/src/core/schema.ts +2 -2
- package/src/core/step.test.ts +2 -1
- package/src/core/step.ts +4 -3
- package/src/core/workflow.test.ts +2 -1
- package/src/core/workflow.ts +4 -3
- package/src/database/backend.test.ts +1 -0
- package/src/database/backend.testsuite.ts +162 -39
- package/src/database/backend.ts +271 -35
- package/src/database/base.test.ts +41 -0
- package/src/database/base.ts +51 -2
- package/src/database/migrations/20251212000000_0_init.ts +2 -1
- package/src/database/migrations/20251212000000_1_tables.ts +2 -1
- package/src/database/migrations/20251212000000_2_fk.ts +2 -1
- package/src/database/migrations/20251212000000_3_indexes.ts +2 -1
- package/src/database/pubsub.test.ts +6 -3
- package/src/database/pubsub.ts +55 -33
- package/src/execution.test.ts +117 -0
- package/src/execution.ts +65 -10
- package/src/internal.ts +21 -1
- package/src/practices/01-remote-workflow.ts +1 -0
- package/src/registry.test.ts +1 -0
- package/src/registry.ts +1 -1
- package/src/testing/connection.ts +3 -1
- package/src/worker.test.ts +2 -0
- package/src/worker.ts +31 -9
- package/src/workflow.test.ts +1 -0
- package/src/workflow.ts +5 -2
- package/templates/openworkflow.config.ts +2 -1
- package/tsdown.config.ts +31 -0
- package/.swcrc +0 -17
- package/dist/chaos.test.d.ts +0 -2
- package/dist/chaos.test.d.ts.map +0 -1
- package/dist/chaos.test.js +0 -92
- package/dist/chaos.test.js.map +0 -1
- package/dist/client.test.d.ts +0 -2
- package/dist/client.test.d.ts.map +0 -1
- package/dist/client.test.js +0 -340
- package/dist/client.test.js.map +0 -1
- package/dist/config.test.d.ts +0 -2
- package/dist/config.test.d.ts.map +0 -1
- package/dist/config.test.js +0 -24
- package/dist/config.test.js.map +0 -1
- package/dist/core/duration.test.d.ts +0 -2
- package/dist/core/duration.test.d.ts.map +0 -1
- package/dist/core/duration.test.js +0 -265
- package/dist/core/duration.test.js.map +0 -1
- package/dist/core/error.test.d.ts +0 -2
- package/dist/core/error.test.d.ts.map +0 -1
- package/dist/core/error.test.js +0 -63
- package/dist/core/error.test.js.map +0 -1
- package/dist/core/json.js +0 -3
- package/dist/core/json.js.map +0 -1
- package/dist/core/result.test.d.ts +0 -2
- package/dist/core/result.test.d.ts.map +0 -1
- package/dist/core/result.test.js +0 -19
- package/dist/core/result.test.js.map +0 -1
- package/dist/core/retry.test.d.ts +0 -2
- package/dist/core/retry.test.d.ts.map +0 -1
- package/dist/core/retry.test.js +0 -37
- package/dist/core/retry.test.js.map +0 -1
- package/dist/core/schema.js +0 -4
- package/dist/core/schema.js.map +0 -1
- package/dist/core/step.test.d.ts +0 -2
- package/dist/core/step.test.d.ts.map +0 -1
- package/dist/core/step.test.js +0 -356
- package/dist/core/step.test.js.map +0 -1
- package/dist/core/workflow.test.d.ts +0 -2
- package/dist/core/workflow.test.d.ts.map +0 -1
- package/dist/core/workflow.test.js +0 -172
- package/dist/core/workflow.test.js.map +0 -1
- package/dist/database/backend.test.d.ts +0 -2
- package/dist/database/backend.test.d.ts.map +0 -1
- package/dist/database/backend.test.js +0 -19
- package/dist/database/backend.test.js.map +0 -1
- package/dist/database/backend.testsuite.d.ts +0 -20
- package/dist/database/backend.testsuite.d.ts.map +0 -1
- package/dist/database/backend.testsuite.js +0 -1174
- package/dist/database/backend.testsuite.js.map +0 -1
- package/dist/database/base.d.ts +0 -12
- package/dist/database/base.d.ts.map +0 -1
- package/dist/database/pubsub.test.d.ts +0 -2
- package/dist/database/pubsub.test.d.ts.map +0 -1
- package/dist/database/pubsub.test.js +0 -86
- package/dist/database/pubsub.test.js.map +0 -1
- package/dist/execution.test.d.ts +0 -2
- package/dist/execution.test.d.ts.map +0 -1
- package/dist/execution.test.js +0 -558
- package/dist/execution.test.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/internal.d.ts.map +0 -1
- package/dist/internal.js.map +0 -1
- package/dist/practices/01-remote-workflow.d.ts +0 -2
- package/dist/practices/01-remote-workflow.d.ts.map +0 -1
- package/dist/practices/01-remote-workflow.js +0 -70
- package/dist/practices/01-remote-workflow.js.map +0 -1
- package/dist/registry.test.d.ts +0 -2
- package/dist/registry.test.d.ts.map +0 -1
- package/dist/registry.test.js +0 -95
- package/dist/registry.test.js.map +0 -1
- package/dist/testing/connection.d.ts +0 -7
- package/dist/testing/connection.d.ts.map +0 -1
- package/dist/testing/connection.js +0 -39
- package/dist/testing/connection.js.map +0 -1
- package/dist/worker.test.d.ts +0 -2
- package/dist/worker.test.d.ts.map +0 -1
- package/dist/worker.test.js +0 -1164
- package/dist/worker.test.js.map +0 -1
- package/dist/workflow.test.d.ts +0 -2
- package/dist/workflow.test.d.ts.map +0 -1
- package/dist/workflow.test.js +0 -73
- package/dist/workflow.test.js.map +0 -1
package/src/database/backend.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { getLogger } from "@logtape/logtape";
|
|
2
2
|
import { camelize } from "inflection";
|
|
3
|
-
import knex
|
|
3
|
+
import knex from "knex";
|
|
4
|
+
import { type Knex } from "knex";
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_NAMESPACE_ID } from "../backend";
|
|
4
7
|
import {
|
|
5
8
|
type Backend,
|
|
6
9
|
type CancelWorkflowRunParams,
|
|
@@ -9,7 +12,6 @@ import {
|
|
|
9
12
|
type CompleteWorkflowRunParams,
|
|
10
13
|
type CreateStepAttemptParams,
|
|
11
14
|
type CreateWorkflowRunParams,
|
|
12
|
-
DEFAULT_NAMESPACE_ID,
|
|
13
15
|
type ExtendWorkflowRunLeaseParams,
|
|
14
16
|
type FailStepAttemptParams,
|
|
15
17
|
type FailWorkflowRunParams,
|
|
@@ -18,13 +20,17 @@ import {
|
|
|
18
20
|
type ListStepAttemptsParams,
|
|
19
21
|
type ListWorkflowRunsParams,
|
|
20
22
|
type PaginatedResponse,
|
|
23
|
+
type PauseWorkflowRunParams,
|
|
24
|
+
type ResumeWorkflowRunParams,
|
|
21
25
|
type SleepWorkflowRunParams,
|
|
22
26
|
} from "../backend";
|
|
23
|
-
import {
|
|
24
|
-
import type
|
|
25
|
-
import type
|
|
27
|
+
import { mergeRetryPolicy } from "../core/retry";
|
|
28
|
+
import { type SerializableRetryPolicy } from "../core/retry";
|
|
29
|
+
import { type StepAttempt } from "../core/step";
|
|
30
|
+
import { type WorkflowRun } from "../core/workflow";
|
|
26
31
|
import { DEFAULT_SCHEMA, migrate } from "./base";
|
|
27
|
-
import {
|
|
32
|
+
import { PostgresPubSub } from "./pubsub";
|
|
33
|
+
import { type OnSubscribed } from "./pubsub";
|
|
28
34
|
|
|
29
35
|
export const DEFAULT_LISTEN_CHANNEL = "new_tasks" as const;
|
|
30
36
|
const DEFAULT_PAGINATION_PAGE_SIZE = 100 as const;
|
|
@@ -155,6 +161,8 @@ export class BackendPostgres implements Backend {
|
|
|
155
161
|
await this.pubsub?.destroy();
|
|
156
162
|
this.pubsub = null;
|
|
157
163
|
await this.knex.destroy();
|
|
164
|
+
this._knex = null;
|
|
165
|
+
this.initialized = false;
|
|
158
166
|
}
|
|
159
167
|
|
|
160
168
|
async createWorkflowRun(params: CreateWorkflowRunParams): Promise<WorkflowRun> {
|
|
@@ -167,6 +175,12 @@ export class BackendPostgres implements Backend {
|
|
|
167
175
|
version: params.version,
|
|
168
176
|
});
|
|
169
177
|
|
|
178
|
+
// config에 retryPolicy를 포함시킵니다.
|
|
179
|
+
const configWithRetryPolicy = {
|
|
180
|
+
...(typeof params.config === "object" && params.config !== null ? params.config : {}),
|
|
181
|
+
retryPolicy: params.retryPolicy ?? undefined,
|
|
182
|
+
};
|
|
183
|
+
|
|
170
184
|
const qb = this.knex
|
|
171
185
|
.withSchema(DEFAULT_SCHEMA)
|
|
172
186
|
.table("workflow_runs")
|
|
@@ -177,7 +191,7 @@ export class BackendPostgres implements Backend {
|
|
|
177
191
|
version: params.version,
|
|
178
192
|
status: "pending",
|
|
179
193
|
idempotency_key: params.idempotencyKey,
|
|
180
|
-
config:
|
|
194
|
+
config: JSON.stringify(configWithRetryPolicy),
|
|
181
195
|
context: params.context,
|
|
182
196
|
input: params.input,
|
|
183
197
|
attempts: 0,
|
|
@@ -247,6 +261,8 @@ export class BackendPostgres implements Backend {
|
|
|
247
261
|
});
|
|
248
262
|
const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
|
|
249
263
|
const { after, before } = params;
|
|
264
|
+
const order = params.order ?? "asc";
|
|
265
|
+
const reverseOrder = order === "asc" ? "desc" : "asc";
|
|
250
266
|
|
|
251
267
|
let cursor: Cursor | null = null;
|
|
252
268
|
if (after) {
|
|
@@ -255,10 +271,10 @@ export class BackendPostgres implements Backend {
|
|
|
255
271
|
cursor = decodeCursor(before);
|
|
256
272
|
}
|
|
257
273
|
|
|
258
|
-
const qb = this.buildListWorkflowRunsWhere(params, cursor);
|
|
274
|
+
const qb = this.buildListWorkflowRunsWhere(params, cursor, order);
|
|
259
275
|
const rows = await qb
|
|
260
|
-
.orderBy("created_at", before ?
|
|
261
|
-
.orderBy("id", before ?
|
|
276
|
+
.orderBy("created_at", before ? reverseOrder : order)
|
|
277
|
+
.orderBy("id", before ? reverseOrder : order)
|
|
262
278
|
.limit(limit + 1);
|
|
263
279
|
|
|
264
280
|
return this.processPaginationResults(
|
|
@@ -269,7 +285,11 @@ export class BackendPostgres implements Backend {
|
|
|
269
285
|
);
|
|
270
286
|
}
|
|
271
287
|
|
|
272
|
-
private buildListWorkflowRunsWhere(
|
|
288
|
+
private buildListWorkflowRunsWhere(
|
|
289
|
+
params: ListWorkflowRunsParams,
|
|
290
|
+
cursor: Cursor | null,
|
|
291
|
+
order: "asc" | "desc",
|
|
292
|
+
) {
|
|
273
293
|
const { after } = params;
|
|
274
294
|
const qb = this.knex
|
|
275
295
|
.withSchema(DEFAULT_SCHEMA)
|
|
@@ -277,13 +297,28 @@ export class BackendPostgres implements Backend {
|
|
|
277
297
|
.where("namespace_id", this.namespaceId);
|
|
278
298
|
|
|
279
299
|
if (cursor) {
|
|
280
|
-
|
|
281
|
-
|
|
300
|
+
// asc: after → ">", before → "<"
|
|
301
|
+
// desc: after → "<", before → ">"
|
|
302
|
+
const operator = (order === "asc") === !!after ? ">" : "<";
|
|
303
|
+
qb.whereRaw(`("created_at", "id") ${operator} (?, ?)`, [
|
|
282
304
|
cursor.createdAt.toISOString(),
|
|
283
305
|
cursor.id,
|
|
284
306
|
]);
|
|
285
307
|
}
|
|
286
308
|
|
|
309
|
+
if (params.status && params.status.length > 0) {
|
|
310
|
+
qb.whereIn("status", params.status);
|
|
311
|
+
}
|
|
312
|
+
if (params.workflowName) {
|
|
313
|
+
qb.where("workflow_name", params.workflowName);
|
|
314
|
+
}
|
|
315
|
+
if (params.createdAfter) {
|
|
316
|
+
qb.where("created_at", ">=", params.createdAfter);
|
|
317
|
+
}
|
|
318
|
+
if (params.createdBefore) {
|
|
319
|
+
qb.where("created_at", "<=", params.createdBefore);
|
|
320
|
+
}
|
|
321
|
+
|
|
287
322
|
return qb;
|
|
288
323
|
}
|
|
289
324
|
|
|
@@ -375,6 +410,11 @@ export class BackendPostgres implements Backend {
|
|
|
375
410
|
.returning("*");
|
|
376
411
|
|
|
377
412
|
if (!updated) {
|
|
413
|
+
const wr = await this.getWorkflowRun({ workflowRunId: params.workflowRunId });
|
|
414
|
+
if (wr && (wr.status === "paused" || wr.status === "canceled")) {
|
|
415
|
+
throw new Error("Workflow run is paused or canceled");
|
|
416
|
+
}
|
|
417
|
+
|
|
378
418
|
logger.error("Failed to extend lease for workflow run: {params}", { params });
|
|
379
419
|
throw new Error("Failed to extend lease for workflow run");
|
|
380
420
|
}
|
|
@@ -459,8 +499,60 @@ export class BackendPostgres implements Backend {
|
|
|
459
499
|
throw new Error("Backend not initialized");
|
|
460
500
|
}
|
|
461
501
|
|
|
462
|
-
const { workflowRunId, error } = params;
|
|
463
|
-
|
|
502
|
+
const { workflowRunId, error, forceComplete, customDelayMs } = params;
|
|
503
|
+
|
|
504
|
+
logger.info("Failing workflow run: {workflowRunId}, {workerId}, {error}", {
|
|
505
|
+
workflowRunId: params.workflowRunId,
|
|
506
|
+
workerId: params.workerId,
|
|
507
|
+
error: params.error,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const workflowRun = await this.knex
|
|
511
|
+
.withSchema(DEFAULT_SCHEMA)
|
|
512
|
+
.table("workflow_runs")
|
|
513
|
+
.where("namespace_id", this.namespaceId)
|
|
514
|
+
.where("id", workflowRunId)
|
|
515
|
+
.first();
|
|
516
|
+
|
|
517
|
+
if (!workflowRun) {
|
|
518
|
+
throw new Error("Workflow run not found");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const config =
|
|
522
|
+
typeof workflowRun.config === "string" ? JSON.parse(workflowRun.config) : workflowRun.config;
|
|
523
|
+
const savedRetryPolicy: SerializableRetryPolicy | undefined = config?.retryPolicy;
|
|
524
|
+
const retryPolicy = mergeRetryPolicy(savedRetryPolicy);
|
|
525
|
+
|
|
526
|
+
const { initialIntervalMs, backoffCoefficient, maximumIntervalMs, maxAttempts } = retryPolicy;
|
|
527
|
+
|
|
528
|
+
const currentAttempts = workflowRun.attempts ?? 0;
|
|
529
|
+
const shouldForceComplete = forceComplete || currentAttempts >= maxAttempts;
|
|
530
|
+
|
|
531
|
+
if (shouldForceComplete) {
|
|
532
|
+
const [updated] = await this.knex
|
|
533
|
+
.withSchema(DEFAULT_SCHEMA)
|
|
534
|
+
.table("workflow_runs")
|
|
535
|
+
.where("namespace_id", this.namespaceId)
|
|
536
|
+
.where("id", workflowRunId)
|
|
537
|
+
.where("status", "running")
|
|
538
|
+
.where("worker_id", params.workerId)
|
|
539
|
+
.update({
|
|
540
|
+
status: "failed",
|
|
541
|
+
available_at: null,
|
|
542
|
+
finished_at: this.knex.fn.now(),
|
|
543
|
+
error: JSON.stringify(error),
|
|
544
|
+
worker_id: null,
|
|
545
|
+
started_at: null,
|
|
546
|
+
updated_at: this.knex.fn.now(),
|
|
547
|
+
})
|
|
548
|
+
.returning("*");
|
|
549
|
+
|
|
550
|
+
if (!updated) {
|
|
551
|
+
logger.error("Failed to mark workflow run failed: {params}", { params });
|
|
552
|
+
throw new Error("Failed to mark workflow run failed");
|
|
553
|
+
}
|
|
554
|
+
return updated;
|
|
555
|
+
}
|
|
464
556
|
|
|
465
557
|
// this beefy query updates a workflow's status, available_at, and
|
|
466
558
|
// finished_at based on the workflow's deadline and retry policy
|
|
@@ -468,15 +560,11 @@ export class BackendPostgres implements Backend {
|
|
|
468
560
|
// if the next retry would exceed the deadline, the run is marked as
|
|
469
561
|
// 'failed' and finalized, otherwise, the run is rescheduled with an updated
|
|
470
562
|
// 'available_at' timestamp for the next retry
|
|
471
|
-
const retryIntervalExpr =
|
|
563
|
+
const retryIntervalExpr = customDelayMs
|
|
564
|
+
? `${customDelayMs} * INTERVAL '1 millisecond'`
|
|
565
|
+
: `LEAST(${initialIntervalMs} * POWER(${backoffCoefficient}, "attempts" - 1), ${maximumIntervalMs}) * INTERVAL '1 millisecond'`;
|
|
472
566
|
const deadlineExceededCondition = `"deadline_at" IS NOT NULL AND NOW() + (${retryIntervalExpr}) >= "deadline_at"`;
|
|
473
567
|
|
|
474
|
-
logger.info("Failing workflow run: {workflowRunId}, {workerId}, {error}", {
|
|
475
|
-
workflowRunId: params.workflowRunId,
|
|
476
|
-
workerId: params.workerId,
|
|
477
|
-
error: params.error,
|
|
478
|
-
});
|
|
479
|
-
|
|
480
568
|
const [updated] = await this.knex
|
|
481
569
|
.withSchema(DEFAULT_SCHEMA)
|
|
482
570
|
.table("workflow_runs")
|
|
@@ -521,7 +609,7 @@ export class BackendPostgres implements Backend {
|
|
|
521
609
|
.table("workflow_runs")
|
|
522
610
|
.where("namespace_id", this.namespaceId)
|
|
523
611
|
.where("id", params.workflowRunId)
|
|
524
|
-
.whereIn("status", ["pending", "running", "sleeping"])
|
|
612
|
+
.whereIn("status", ["pending", "running", "sleeping", "paused"])
|
|
525
613
|
.update({
|
|
526
614
|
status: "canceled",
|
|
527
615
|
worker_id: null,
|
|
@@ -564,6 +652,111 @@ export class BackendPostgres implements Backend {
|
|
|
564
652
|
return updated;
|
|
565
653
|
}
|
|
566
654
|
|
|
655
|
+
async pauseWorkflowRun(params: PauseWorkflowRunParams): Promise<WorkflowRun> {
|
|
656
|
+
if (!this.initialized) {
|
|
657
|
+
throw new Error("Backend not initialized");
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
logger.info("Pausing workflow run: {workflowRunId}", { workflowRunId: params.workflowRunId });
|
|
661
|
+
|
|
662
|
+
const [updated] = await this.knex
|
|
663
|
+
.withSchema(DEFAULT_SCHEMA)
|
|
664
|
+
.table("workflow_runs")
|
|
665
|
+
.where("namespace_id", this.namespaceId)
|
|
666
|
+
.where("id", params.workflowRunId)
|
|
667
|
+
.whereIn("status", ["pending", "running", "sleeping"])
|
|
668
|
+
.update({
|
|
669
|
+
status: "paused",
|
|
670
|
+
worker_id: null,
|
|
671
|
+
available_at: null,
|
|
672
|
+
updated_at: this.knex.fn.now(),
|
|
673
|
+
})
|
|
674
|
+
.returning("*");
|
|
675
|
+
|
|
676
|
+
if (!updated) {
|
|
677
|
+
const existing = await this.getWorkflowRun({
|
|
678
|
+
workflowRunId: params.workflowRunId,
|
|
679
|
+
});
|
|
680
|
+
if (!existing) {
|
|
681
|
+
throw new Error(`Workflow run ${params.workflowRunId} does not exist`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// 이미 paused이면 멱등하게 반환합니다.
|
|
685
|
+
if (existing.status === "paused") {
|
|
686
|
+
return existing;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// 터미널 상태에서는 pause할 수 없습니다.
|
|
690
|
+
// 'succeeded' status is deprecated
|
|
691
|
+
if (["succeeded", "completed", "failed", "canceled"].includes(existing.status)) {
|
|
692
|
+
logger.error("Cannot pause workflow run: {params} with status {status}", {
|
|
693
|
+
params,
|
|
694
|
+
status: existing.status,
|
|
695
|
+
});
|
|
696
|
+
throw new Error(
|
|
697
|
+
`Cannot pause workflow run ${params.workflowRunId} with status ${existing.status}`,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
logger.error("Failed to pause workflow run: {params}", { params });
|
|
702
|
+
throw new Error("Failed to pause workflow run");
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return updated;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async resumeWorkflowRun(params: ResumeWorkflowRunParams): Promise<WorkflowRun> {
|
|
709
|
+
if (!this.initialized) {
|
|
710
|
+
throw new Error("Backend not initialized");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
logger.info("Resuming workflow run: {workflowRunId}", { workflowRunId: params.workflowRunId });
|
|
714
|
+
|
|
715
|
+
const [updated] = await this.knex
|
|
716
|
+
.withSchema(DEFAULT_SCHEMA)
|
|
717
|
+
.table("workflow_runs")
|
|
718
|
+
.where("namespace_id", this.namespaceId)
|
|
719
|
+
.where("id", params.workflowRunId)
|
|
720
|
+
.where("status", "paused")
|
|
721
|
+
.update({
|
|
722
|
+
status: "pending",
|
|
723
|
+
available_at: this.knex.fn.now(),
|
|
724
|
+
updated_at: this.knex.fn.now(),
|
|
725
|
+
})
|
|
726
|
+
.returning("*");
|
|
727
|
+
|
|
728
|
+
if (!updated) {
|
|
729
|
+
const existing = await this.getWorkflowRun({
|
|
730
|
+
workflowRunId: params.workflowRunId,
|
|
731
|
+
});
|
|
732
|
+
if (!existing) {
|
|
733
|
+
throw new Error(`Workflow run ${params.workflowRunId} does not exist`);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// 이미 pending/running이면 멱등하게 반환합니다.
|
|
737
|
+
if (existing.status === "pending" || existing.status === "running") {
|
|
738
|
+
return existing;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// 터미널 상태에서는 resume할 수 없습니다.
|
|
742
|
+
// 'succeeded' status is deprecated
|
|
743
|
+
if (["succeeded", "completed", "failed", "canceled"].includes(existing.status)) {
|
|
744
|
+
logger.error("Cannot resume workflow run: {params} with status {status}", {
|
|
745
|
+
params,
|
|
746
|
+
status: existing.status,
|
|
747
|
+
});
|
|
748
|
+
throw new Error(
|
|
749
|
+
`Cannot resume workflow run ${params.workflowRunId} with status ${existing.status}`,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
logger.error("Failed to resume workflow run: {params}", { params });
|
|
754
|
+
throw new Error("Failed to resume workflow run");
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return updated;
|
|
758
|
+
}
|
|
759
|
+
|
|
567
760
|
async createStepAttempt(params: CreateStepAttemptParams): Promise<StepAttempt> {
|
|
568
761
|
if (!this.initialized) {
|
|
569
762
|
throw new Error("Backend not initialized");
|
|
@@ -631,6 +824,8 @@ export class BackendPostgres implements Backend {
|
|
|
631
824
|
|
|
632
825
|
const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
|
|
633
826
|
const { after, before } = params;
|
|
827
|
+
const order = params.order ?? "asc";
|
|
828
|
+
const reverseOrder = order === "asc" ? "desc" : "asc";
|
|
634
829
|
|
|
635
830
|
let cursor: Cursor | null = null;
|
|
636
831
|
if (after) {
|
|
@@ -639,10 +834,10 @@ export class BackendPostgres implements Backend {
|
|
|
639
834
|
cursor = decodeCursor(before);
|
|
640
835
|
}
|
|
641
836
|
|
|
642
|
-
const qb = this.buildListStepAttemptsWhere(params, cursor);
|
|
837
|
+
const qb = this.buildListStepAttemptsWhere(params, cursor, order);
|
|
643
838
|
const rows = await qb
|
|
644
|
-
.orderBy("created_at", before ?
|
|
645
|
-
.orderBy("id", before ?
|
|
839
|
+
.orderBy("created_at", before ? reverseOrder : order)
|
|
840
|
+
.orderBy("id", before ? reverseOrder : order)
|
|
646
841
|
.limit(limit + 1);
|
|
647
842
|
|
|
648
843
|
return this.processPaginationResults(
|
|
@@ -653,7 +848,11 @@ export class BackendPostgres implements Backend {
|
|
|
653
848
|
);
|
|
654
849
|
}
|
|
655
850
|
|
|
656
|
-
private buildListStepAttemptsWhere(
|
|
851
|
+
private buildListStepAttemptsWhere(
|
|
852
|
+
params: ListStepAttemptsParams,
|
|
853
|
+
cursor: Cursor | null,
|
|
854
|
+
order: "asc" | "desc",
|
|
855
|
+
) {
|
|
657
856
|
const { after } = params;
|
|
658
857
|
const qb = this.knex
|
|
659
858
|
.withSchema(DEFAULT_SCHEMA)
|
|
@@ -662,7 +861,9 @@ export class BackendPostgres implements Backend {
|
|
|
662
861
|
.where("workflow_run_id", params.workflowRunId);
|
|
663
862
|
|
|
664
863
|
if (cursor) {
|
|
665
|
-
|
|
864
|
+
// asc: after → ">", before → "<"
|
|
865
|
+
// desc: after → "<", before → ">"
|
|
866
|
+
const operator = (order === "asc") === !!after ? ">" : "<";
|
|
666
867
|
return qb.whereRaw(`("created_at", "id") ${operator} (?, ?)`, [
|
|
667
868
|
cursor.createdAt.toISOString(),
|
|
668
869
|
cursor.id,
|
|
@@ -713,8 +914,10 @@ export class BackendPostgres implements Backend {
|
|
|
713
914
|
};
|
|
714
915
|
}
|
|
715
916
|
|
|
716
|
-
//
|
|
717
|
-
|
|
917
|
+
// WHERE 조건에 wr.status='running', sa.status='running'이 포함되어 있어,
|
|
918
|
+
// 외부에서 워크플로우 상태가 변경된 경우(pause/cancel) null을 반환합니다.
|
|
919
|
+
// 예상하지 못한 이유로 실패한 경우에는 에러를 로깅합니다.
|
|
920
|
+
async completeStepAttempt(params: CompleteStepAttemptParams): Promise<StepAttempt | null> {
|
|
718
921
|
if (!this.initialized) {
|
|
719
922
|
throw new Error("Backend not initialized");
|
|
720
923
|
}
|
|
@@ -747,14 +950,13 @@ export class BackendPostgres implements Backend {
|
|
|
747
950
|
.returning("sa.*");
|
|
748
951
|
|
|
749
952
|
if (!updated) {
|
|
750
|
-
|
|
751
|
-
throw new Error("Failed to mark step attempt completed");
|
|
953
|
+
return this.handleStepAttemptUpdateMiss("completed", params);
|
|
752
954
|
}
|
|
753
955
|
|
|
754
956
|
return updated;
|
|
755
957
|
}
|
|
756
958
|
|
|
757
|
-
async failStepAttempt(params: FailStepAttemptParams): Promise<StepAttempt> {
|
|
959
|
+
async failStepAttempt(params: FailStepAttemptParams): Promise<StepAttempt | null> {
|
|
758
960
|
if (!this.initialized) {
|
|
759
961
|
throw new Error("Backend not initialized");
|
|
760
962
|
}
|
|
@@ -788,12 +990,46 @@ export class BackendPostgres implements Backend {
|
|
|
788
990
|
.returning("sa.*");
|
|
789
991
|
|
|
790
992
|
if (!updated) {
|
|
791
|
-
|
|
792
|
-
throw new Error("Failed to mark step attempt failed");
|
|
993
|
+
return this.handleStepAttemptUpdateMiss("failed", params);
|
|
793
994
|
}
|
|
794
995
|
|
|
795
996
|
return updated;
|
|
796
997
|
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* completeStepAttempt/failStepAttempt에서 UPDATE가 0건일 때,
|
|
1001
|
+
* 외부 상태 변경(pause/cancel)에 의한 것인지 판단합니다.
|
|
1002
|
+
* - 외부 상태 변경이면 해당 step의 상태도 워크플로우와 동일하게 맞추고 null을 반환합니다.
|
|
1003
|
+
* - 그 외에는 예상하지 못한 상황이므로 에러를 throw합니다.
|
|
1004
|
+
*/
|
|
1005
|
+
private async handleStepAttemptUpdateMiss(
|
|
1006
|
+
method: string,
|
|
1007
|
+
params: { workflowRunId: string; stepAttemptId: string; workerId: string },
|
|
1008
|
+
): Promise<null> {
|
|
1009
|
+
const wr = await this.getWorkflowRun({ workflowRunId: params.workflowRunId });
|
|
1010
|
+
|
|
1011
|
+
// 워크플로우가 외부에서 paused/canceled된 경우 → step 상태도 동일하게 갱신하고 null 반환
|
|
1012
|
+
if (wr && (wr.status === "paused" || wr.status === "canceled")) {
|
|
1013
|
+
await this.knex
|
|
1014
|
+
.withSchema(DEFAULT_SCHEMA)
|
|
1015
|
+
.table("step_attempts")
|
|
1016
|
+
.where("namespace_id", this.namespaceId)
|
|
1017
|
+
.where("id", params.stepAttemptId)
|
|
1018
|
+
.whereIn("status", ["running", "paused"])
|
|
1019
|
+
.update({
|
|
1020
|
+
status: wr.status,
|
|
1021
|
+
updated_at: this.knex.fn.now(),
|
|
1022
|
+
});
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// 그 외(워크플로우가 여전히 running인데 UPDATE가 안 된 경우 등) → 예상 못한 상황
|
|
1027
|
+
logger.error("Failed to mark step attempt {method}: {params}", {
|
|
1028
|
+
method,
|
|
1029
|
+
params,
|
|
1030
|
+
});
|
|
1031
|
+
throw new Error(`Failed to mark step attempt ${method}`);
|
|
1032
|
+
}
|
|
797
1033
|
}
|
|
798
1034
|
|
|
799
1035
|
/**
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { createMigrationSource } from "./base";
|
|
8
|
+
|
|
9
|
+
describe("createMigrationSource", () => {
|
|
10
|
+
test("preserves emitted migration filenames for knex identity", async () => {
|
|
11
|
+
const migrationsDir = await mkdtemp(path.join(tmpdir(), "sonamu-tasks-migrations-"));
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
await Promise.all([
|
|
15
|
+
writeFile(
|
|
16
|
+
path.join(migrationsDir, "20251212000000_0_init.js"),
|
|
17
|
+
"export const up = async () => {}; export const down = async () => {};",
|
|
18
|
+
),
|
|
19
|
+
writeFile(path.join(migrationsDir, "20251212000000_0_init.d.ts"), "export {};"),
|
|
20
|
+
writeFile(
|
|
21
|
+
path.join(migrationsDir, "20251212000000_1_tables.ts"),
|
|
22
|
+
"export const up = async () => {}; export const down = async () => {};",
|
|
23
|
+
),
|
|
24
|
+
writeFile(path.join(migrationsDir, "20251212000000_2_fk.js.map"), "{}"),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const migrationSource = createMigrationSource(migrationsDir);
|
|
28
|
+
const migrations = await migrationSource.getMigrations([]);
|
|
29
|
+
|
|
30
|
+
expect(migrations.map((migration) => migration.fileName)).toStrictEqual([
|
|
31
|
+
"20251212000000_0_init.js",
|
|
32
|
+
"20251212000000_1_tables.ts",
|
|
33
|
+
]);
|
|
34
|
+
expect(
|
|
35
|
+
migrations.map((migration) => migrationSource.getMigrationName(migration)),
|
|
36
|
+
).toStrictEqual(["20251212000000_0_init.js", "20251212000000_1_tables.ts"]);
|
|
37
|
+
} finally {
|
|
38
|
+
await rm(migrationsDir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/database/base.ts
CHANGED
|
@@ -1,7 +1,55 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
|
-
import
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
import knex from "knex";
|
|
6
|
+
import { type Knex } from "knex";
|
|
3
7
|
|
|
4
8
|
export const DEFAULT_SCHEMA = "sonamu_tasks";
|
|
9
|
+
const MIGRATION_FILE_PATTERN = /\.(?:[cm]?[jt]s)$/;
|
|
10
|
+
const TYPE_DECLARATION_FILE_PATTERN = /\.d\.[cm]?[jt]s$/;
|
|
11
|
+
|
|
12
|
+
type MigrationModule = {
|
|
13
|
+
up: (knex: Knex) => Promise<void>;
|
|
14
|
+
down: (knex: Knex) => Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type MigrationEntry = {
|
|
18
|
+
canonicalName: string;
|
|
19
|
+
fileName: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function toCanonicalMigrationName(fileName: string): string {
|
|
23
|
+
return fileName.replace(/\.(?:[cm]?[jt]s)$/, ".ts");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function listMigrationEntries(directory: string): Promise<MigrationEntry[]> {
|
|
27
|
+
const dirents = await readdir(directory, { withFileTypes: true });
|
|
28
|
+
|
|
29
|
+
return dirents
|
|
30
|
+
.filter(
|
|
31
|
+
(dirent) =>
|
|
32
|
+
dirent.isFile() &&
|
|
33
|
+
MIGRATION_FILE_PATTERN.test(dirent.name) &&
|
|
34
|
+
!TYPE_DECLARATION_FILE_PATTERN.test(dirent.name),
|
|
35
|
+
)
|
|
36
|
+
.map((dirent) => ({
|
|
37
|
+
canonicalName: toCanonicalMigrationName(dirent.name),
|
|
38
|
+
fileName: dirent.name,
|
|
39
|
+
}))
|
|
40
|
+
.sort((left, right) => left.canonicalName.localeCompare(right.canonicalName));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createMigrationSource(directory: string): Knex.MigrationSource<MigrationEntry> {
|
|
44
|
+
return {
|
|
45
|
+
getMigrations: async (_loadExtensions) => listMigrationEntries(directory),
|
|
46
|
+
getMigrationName: (migration) => migration.fileName,
|
|
47
|
+
getMigration: async (migration): Promise<MigrationModule> => {
|
|
48
|
+
const migrationUrl = pathToFileURL(path.join(directory, migration.fileName)).href;
|
|
49
|
+
return import(migrationUrl) as Promise<MigrationModule>;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
5
53
|
|
|
6
54
|
/**
|
|
7
55
|
* migrate applies pending migrations to the database. Does nothing if the
|
|
@@ -10,9 +58,10 @@ export const DEFAULT_SCHEMA = "sonamu_tasks";
|
|
|
10
58
|
export async function migrate(config: Knex.Config, schema: string) {
|
|
11
59
|
const instance = knex({ ...config, pool: { min: 1, max: 1 } });
|
|
12
60
|
try {
|
|
61
|
+
const migrationDirectory = path.join(import.meta.dirname, "migrations");
|
|
13
62
|
await instance.schema.createSchemaIfNotExists(schema);
|
|
14
63
|
await instance.migrate.latest({
|
|
15
|
-
|
|
64
|
+
migrationSource: createMigrationSource(migrationDirectory),
|
|
16
65
|
schemaName: schema,
|
|
17
66
|
});
|
|
18
67
|
} finally {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import knex
|
|
1
|
+
import knex from "knex";
|
|
2
|
+
import { type Knex } from "knex";
|
|
2
3
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
-
|
|
4
|
+
|
|
5
|
+
import { type Result } from "../core/result";
|
|
4
6
|
import { KNEX_GLOBAL_CONFIG } from "../testing/connection";
|
|
5
|
-
import {
|
|
7
|
+
import { PostgresPubSub } from "./pubsub";
|
|
8
|
+
import { type OnSubscribed } from "./pubsub";
|
|
6
9
|
|
|
7
10
|
describe("PostgresPubSub", () => {
|
|
8
11
|
let knexInstance: Knex;
|