@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.
- package/.oxlintrc.json +3 -0
- package/AGENTS.md +21 -0
- package/dist/backend.d.ts +126 -107
- 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 +219 -213
- 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 +37 -31
- package/dist/core/retry.d.ts.map +1 -1
- package/dist/core/retry.js +44 -51
- 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 -577
- 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 -59
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +175 -188
- 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 -13
- 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 -199
- package/dist/worker.js.map +1 -1
- package/dist/workflow.d.ts +26 -30
- 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 +17 -19
- package/src/backend.ts +25 -9
- package/src/chaos.test.ts +3 -1
- package/src/client.test.ts +2 -0
- package/src/client.ts +30 -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 +3 -2
- package/src/core/retry.ts +1 -1
- 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 +44 -40
- package/src/database/backend.ts +207 -25
- 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 +2 -0
- package/src/execution.ts +49 -10
- package/src/internal.ts +15 -15
- 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 +30 -9
- package/src/workflow.test.ts +1 -0
- package/src/workflow.ts +3 -3
- 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 -198
- 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 -1280
- 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 -662
- 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
|
@@ -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;
|
package/src/database/pubsub.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import assert from "assert";
|
|
2
|
-
|
|
3
|
-
import {
|
|
2
|
+
|
|
3
|
+
import { type Knex } from "knex";
|
|
4
|
+
|
|
5
|
+
import { err, ok } from "../core/result";
|
|
6
|
+
import { type Result } from "../core/result";
|
|
4
7
|
|
|
5
8
|
export type OnSubscribed = (result: Result<string | null>) => void | Promise<void>;
|
|
6
9
|
|
|
7
10
|
export class PostgresPubSub {
|
|
8
11
|
private _destroyed = false;
|
|
12
|
+
private _connecting = false;
|
|
9
13
|
private _onClosed: () => Promise<void>;
|
|
14
|
+
private _onNotification: (msg: { channel: string; payload: unknown }) => Promise<void>;
|
|
15
|
+
private _onError: (error: Error) => Promise<void>;
|
|
10
16
|
private _listeners = new Map<string, Set<OnSubscribed>>();
|
|
11
17
|
|
|
12
|
-
//
|
|
18
|
+
// oxlint-disable-next-line @typescript-eslint/no-explicit-any -- Knex exposes a connection as any
|
|
13
19
|
private _connection: any | null = null;
|
|
14
20
|
|
|
15
21
|
private constructor(private readonly knex: Knex) {
|
|
@@ -21,46 +27,60 @@ export class PostgresPubSub {
|
|
|
21
27
|
|
|
22
28
|
await this.connect();
|
|
23
29
|
}).bind(this);
|
|
24
|
-
}
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
this._onNotification = (async ({
|
|
32
|
+
channel,
|
|
33
|
+
payload: rawPayload,
|
|
34
|
+
}: {
|
|
35
|
+
channel: string;
|
|
36
|
+
payload: unknown;
|
|
37
|
+
}) => {
|
|
38
|
+
const payload = typeof rawPayload === "string" && rawPayload.length !== 0 ? rawPayload : null;
|
|
39
|
+
const listeners = this._listeners.get(channel);
|
|
40
|
+
if (!listeners) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
29
43
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const payload =
|
|
38
|
-
typeof rawPayload === "string" && rawPayload.length !== 0 ? rawPayload : null;
|
|
39
|
-
const listeners = this._listeners.get(channel);
|
|
40
|
-
if (!listeners) {
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const result = ok(payload);
|
|
45
|
-
await Promise.allSettled(
|
|
46
|
-
Array.from(listeners.values()).map((listener) => Promise.resolve(listener(result))),
|
|
47
|
-
);
|
|
48
|
-
},
|
|
49
|
-
);
|
|
50
|
-
connection.on("error", async (error: Error) => {
|
|
44
|
+
const result = ok(payload);
|
|
45
|
+
await Promise.allSettled(
|
|
46
|
+
Array.from(listeners.values()).map((listener) => Promise.resolve(listener(result))),
|
|
47
|
+
);
|
|
48
|
+
}).bind(this);
|
|
49
|
+
|
|
50
|
+
this._onError = (async (error: Error) => {
|
|
51
51
|
const result = err(error);
|
|
52
52
|
await Promise.allSettled(
|
|
53
53
|
Array.from(this._listeners.values())
|
|
54
54
|
.flatMap((listeners) => Array.from(listeners))
|
|
55
55
|
.map((listener) => Promise.resolve(listener(result))),
|
|
56
56
|
);
|
|
57
|
-
});
|
|
57
|
+
}).bind(this);
|
|
58
|
+
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
get destroyed() {
|
|
61
|
+
return this._destroyed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// acquire new raw connection and set up listeners
|
|
65
|
+
async connect() {
|
|
66
|
+
// 동시 재연결 시도로 인한 연결 누수를 방지합니다.
|
|
67
|
+
if (this._connecting) return;
|
|
68
|
+
this._connecting = true;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const connection = await this.knex.client.acquireRawConnection();
|
|
72
|
+
connection.on("close", this._onClosed);
|
|
73
|
+
connection.on("notification", this._onNotification);
|
|
74
|
+
connection.on("error", this._onError);
|
|
62
75
|
|
|
63
|
-
|
|
76
|
+
for (const channel of this._listeners.keys()) {
|
|
77
|
+
connection.query(`LISTEN ${channel}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this._connection = connection;
|
|
81
|
+
} finally {
|
|
82
|
+
this._connecting = false;
|
|
83
|
+
}
|
|
64
84
|
}
|
|
65
85
|
|
|
66
86
|
// destroy the listener and close the connection, do not destroy the knex connection
|
|
@@ -70,6 +90,8 @@ export class PostgresPubSub {
|
|
|
70
90
|
}
|
|
71
91
|
try {
|
|
72
92
|
this._connection.off("close", this._onClosed);
|
|
93
|
+
this._connection.off("notification", this._onNotification);
|
|
94
|
+
this._connection.off("error", this._onError);
|
|
73
95
|
await this.knex.client.destroyRawConnection(this._connection);
|
|
74
96
|
} finally {
|
|
75
97
|
this._destroyed = true;
|
package/src/execution.test.ts
CHANGED
package/src/execution.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type
|
|
1
|
+
import { type Backend } from "./backend";
|
|
2
|
+
import { type DurationString } from "./core/duration";
|
|
3
3
|
import { serializeError } from "./core/error";
|
|
4
|
-
import type
|
|
5
|
-
import { isDynamicRetryPolicy
|
|
6
|
-
import
|
|
4
|
+
import { type JsonValue } from "./core/json";
|
|
5
|
+
import { isDynamicRetryPolicy } from "./core/retry";
|
|
6
|
+
import { type RetryPolicy } from "./core/retry";
|
|
7
|
+
import { type StepAttempt, type StepAttemptCache } from "./core/step";
|
|
7
8
|
import {
|
|
8
9
|
addToStepAttemptCache,
|
|
9
10
|
calculateSleepResumeAt,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
getCachedStepAttempt,
|
|
13
14
|
normalizeStepOutput,
|
|
14
15
|
} from "./core/step";
|
|
15
|
-
import type
|
|
16
|
+
import { type WorkflowRun } from "./core/workflow";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Config for an individual step defined with `step.run()`.
|
|
@@ -70,6 +71,16 @@ class SleepSignal extends Error {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* 외부에서 workflow 상태가 변경되었을 때 실행을 안전하게 중단하기 위한 에러입니다.
|
|
76
|
+
*/
|
|
77
|
+
class WorkflowAbortedError extends Error {
|
|
78
|
+
constructor() {
|
|
79
|
+
super("Workflow execution aborted");
|
|
80
|
+
this.name = "WorkflowAbortedError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
73
84
|
/**
|
|
74
85
|
* Configures the options for a StepExecutor.
|
|
75
86
|
*/
|
|
@@ -78,6 +89,7 @@ export interface StepExecutorOptions {
|
|
|
78
89
|
workflowRunId: string;
|
|
79
90
|
workerId: string;
|
|
80
91
|
attempts: StepAttempt[];
|
|
92
|
+
signal?: AbortSignal;
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
/**
|
|
@@ -88,12 +100,14 @@ export class StepExecutor implements StepApi {
|
|
|
88
100
|
private readonly backend: Backend;
|
|
89
101
|
private readonly workflowRunId: string;
|
|
90
102
|
private readonly workerId: string;
|
|
103
|
+
private readonly signal?: AbortSignal;
|
|
91
104
|
private cache: StepAttemptCache;
|
|
92
105
|
|
|
93
106
|
constructor(options: Readonly<StepExecutorOptions>) {
|
|
94
107
|
this.backend = options.backend;
|
|
95
108
|
this.workflowRunId = options.workflowRunId;
|
|
96
109
|
this.workerId = options.workerId;
|
|
110
|
+
this.signal = options.signal;
|
|
97
111
|
|
|
98
112
|
this.cache = createStepAttemptCacheFromAttempts(options.attempts);
|
|
99
113
|
}
|
|
@@ -103,6 +117,9 @@ export class StepExecutor implements StepApi {
|
|
|
103
117
|
fn: StepFunction<Output>,
|
|
104
118
|
): Promise<Output> {
|
|
105
119
|
const { name } = config;
|
|
120
|
+
if (this.signal?.aborted) {
|
|
121
|
+
throw new WorkflowAbortedError();
|
|
122
|
+
}
|
|
106
123
|
|
|
107
124
|
// return cached result if available
|
|
108
125
|
const existingAttempt = getCachedStepAttempt(this.cache, name);
|
|
@@ -125,31 +142,42 @@ export class StepExecutor implements StepApi {
|
|
|
125
142
|
const result = await fn();
|
|
126
143
|
const output = normalizeStepOutput(result);
|
|
127
144
|
|
|
128
|
-
// mark success
|
|
145
|
+
// mark success — null이면 외부에서 워크플로우 상태가 변경된 것입니다(pause/cancel).
|
|
129
146
|
const savedAttempt = await this.backend.completeStepAttempt({
|
|
130
147
|
workflowRunId: this.workflowRunId,
|
|
131
148
|
stepAttemptId: attempt.id,
|
|
132
149
|
workerId: this.workerId,
|
|
133
150
|
output,
|
|
134
151
|
});
|
|
152
|
+
if (!savedAttempt) {
|
|
153
|
+
throw new WorkflowAbortedError();
|
|
154
|
+
}
|
|
135
155
|
|
|
136
156
|
// cache result
|
|
137
157
|
this.cache = addToStepAttemptCache(this.cache, savedAttempt);
|
|
138
158
|
|
|
139
159
|
return savedAttempt.output as Output;
|
|
140
160
|
} catch (error) {
|
|
141
|
-
// mark failure
|
|
142
|
-
await this.backend.failStepAttempt({
|
|
161
|
+
// mark failure — null이면 외부에서 워크플로우 상태가 변경된 것입니다(pause/cancel).
|
|
162
|
+
const failed = await this.backend.failStepAttempt({
|
|
143
163
|
workflowRunId: this.workflowRunId,
|
|
144
164
|
stepAttemptId: attempt.id,
|
|
145
165
|
workerId: this.workerId,
|
|
146
166
|
error: serializeError(error),
|
|
147
167
|
});
|
|
168
|
+
if (!failed) {
|
|
169
|
+
throw new WorkflowAbortedError();
|
|
170
|
+
}
|
|
171
|
+
|
|
148
172
|
throw error;
|
|
149
173
|
}
|
|
150
174
|
}
|
|
151
175
|
|
|
152
176
|
async sleep(name: string, duration: DurationString): Promise<void> {
|
|
177
|
+
if (this.signal?.aborted) {
|
|
178
|
+
throw new WorkflowAbortedError();
|
|
179
|
+
}
|
|
180
|
+
|
|
153
181
|
// return cached result if this sleep already completed
|
|
154
182
|
const existingAttempt = getCachedStepAttempt(this.cache, name);
|
|
155
183
|
if (existingAttempt) return;
|
|
@@ -188,6 +216,7 @@ export interface ExecuteWorkflowParams {
|
|
|
188
216
|
workflowVersion: string | null;
|
|
189
217
|
workerId: string;
|
|
190
218
|
retryPolicy?: RetryPolicy;
|
|
219
|
+
signal?: AbortSignal;
|
|
191
220
|
}
|
|
192
221
|
|
|
193
222
|
/**
|
|
@@ -200,7 +229,8 @@ export interface ExecuteWorkflowParams {
|
|
|
200
229
|
* @param params - The execution parameters
|
|
201
230
|
*/
|
|
202
231
|
export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>): Promise<void> {
|
|
203
|
-
const { backend, workflowRun, workflowFn, workflowVersion, workerId, retryPolicy } =
|
|
232
|
+
const { backend, workflowRun, workflowFn, workflowVersion, workerId, retryPolicy, signal } =
|
|
233
|
+
params;
|
|
204
234
|
|
|
205
235
|
try {
|
|
206
236
|
// load all pages of step history
|
|
@@ -244,6 +274,9 @@ export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>):
|
|
|
244
274
|
workerId,
|
|
245
275
|
output: null,
|
|
246
276
|
});
|
|
277
|
+
if (!completed) {
|
|
278
|
+
throw new WorkflowAbortedError();
|
|
279
|
+
}
|
|
247
280
|
|
|
248
281
|
// update cache w/ completed attempt
|
|
249
282
|
attempts[i] = completed;
|
|
@@ -256,6 +289,7 @@ export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>):
|
|
|
256
289
|
workflowRunId: workflowRun.id,
|
|
257
290
|
workerId,
|
|
258
291
|
attempts,
|
|
292
|
+
signal,
|
|
259
293
|
});
|
|
260
294
|
|
|
261
295
|
// execute workflow
|
|
@@ -283,6 +317,11 @@ export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>):
|
|
|
283
317
|
return;
|
|
284
318
|
}
|
|
285
319
|
|
|
320
|
+
// heartbeat 실패로 abort된 경우, failWorkflowRun을 호출하지 않고 조용히 종료합니다.
|
|
321
|
+
if (error instanceof WorkflowAbortedError || signal?.aborted) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
286
325
|
// claimWorkflowRun에서 이미 attempts가 증가된 상태입니다.
|
|
287
326
|
let forceComplete = false;
|
|
288
327
|
let customDelayMs: number | undefined;
|
package/src/internal.ts
CHANGED
|
@@ -3,26 +3,26 @@ export type * from "./client";
|
|
|
3
3
|
export { loadConfig } from "./config";
|
|
4
4
|
export type { DurationString } from "./core/duration";
|
|
5
5
|
export type { JsonValue } from "./core/json";
|
|
6
|
+
export type {
|
|
7
|
+
DynamicRetryPolicy,
|
|
8
|
+
MergedDynamicRetryPolicy,
|
|
9
|
+
MergedRetryPolicy,
|
|
10
|
+
MergedStaticRetryPolicy,
|
|
11
|
+
RetryDecision,
|
|
12
|
+
RetryDecisionFn,
|
|
13
|
+
RetryPolicy,
|
|
14
|
+
SerializableRetryPolicy,
|
|
15
|
+
StaticRetryPolicy,
|
|
16
|
+
} from "./core/retry";
|
|
6
17
|
export {
|
|
18
|
+
calculateRetryDelayMs,
|
|
7
19
|
DEFAULT_RETRY_POLICY,
|
|
20
|
+
isDynamicRetryPolicy,
|
|
21
|
+
isStaticRetryPolicy,
|
|
8
22
|
mergeRetryPolicy,
|
|
9
23
|
serializeRetryPolicy,
|
|
10
|
-
shouldRetryByPolicy,
|
|
11
|
-
calculateRetryDelayMs,
|
|
12
24
|
shouldRetry,
|
|
13
|
-
|
|
14
|
-
isStaticRetryPolicy,
|
|
15
|
-
} from "./core/retry";
|
|
16
|
-
export type {
|
|
17
|
-
RetryPolicy,
|
|
18
|
-
StaticRetryPolicy,
|
|
19
|
-
DynamicRetryPolicy,
|
|
20
|
-
SerializableRetryPolicy,
|
|
21
|
-
RetryDecision,
|
|
22
|
-
RetryDecisionFn,
|
|
23
|
-
MergedStaticRetryPolicy,
|
|
24
|
-
MergedDynamicRetryPolicy,
|
|
25
|
-
MergedRetryPolicy,
|
|
25
|
+
shouldRetryByPolicy,
|
|
26
26
|
} from "./core/retry";
|
|
27
27
|
export type { StandardSchemaV1 } from "./core/schema";
|
|
28
28
|
export type { StepAttempt } from "./core/step";
|
package/src/registry.test.ts
CHANGED
package/src/registry.ts
CHANGED
package/src/worker.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
2
3
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
4
|
+
|
|
3
5
|
import { declareWorkflow, OpenWorkflow } from "./client";
|
|
4
6
|
import { BackendPostgres } from "./database/backend";
|
|
5
7
|
import { KNEX_GLOBAL_CONFIG } from "./testing/connection";
|
package/src/worker.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
import type
|
|
2
|
+
|
|
3
|
+
import { type Backend } from "./backend";
|
|
4
|
+
import { type WorkflowRun } from "./core/workflow";
|
|
4
5
|
import { executeWorkflow } from "./execution";
|
|
5
|
-
import type
|
|
6
|
-
import type
|
|
6
|
+
import { type WorkflowRegistry } from "./registry";
|
|
7
|
+
import { type Workflow } from "./workflow";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_LEASE_DURATION_MS = 30 * 1000; // 30s
|
|
9
10
|
const DEFAULT_POLL_INTERVAL_MS = 100; // 100ms
|
|
@@ -32,6 +33,7 @@ export class Worker {
|
|
|
32
33
|
private readonly activeExecutions = new Set<WorkflowExecution>();
|
|
33
34
|
private running = false;
|
|
34
35
|
private loopPromise: Promise<void> | null = null;
|
|
36
|
+
private subscribed = false;
|
|
35
37
|
|
|
36
38
|
private usePubSub: boolean;
|
|
37
39
|
private listenDelay: number;
|
|
@@ -72,6 +74,8 @@ export class Worker {
|
|
|
72
74
|
|
|
73
75
|
// wait for all active executions to finish
|
|
74
76
|
while (this.activeExecutions.size > 0) await sleep(100);
|
|
77
|
+
|
|
78
|
+
this.subscribed = false;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
/**
|
|
@@ -108,9 +112,10 @@ export class Worker {
|
|
|
108
112
|
* Only sleeps when no work was claimed to avoid busy-waiting.
|
|
109
113
|
*/
|
|
110
114
|
private async runLoop(): Promise<void> {
|
|
111
|
-
if (this.usePubSub) {
|
|
115
|
+
if (this.usePubSub && !this.subscribed) {
|
|
116
|
+
this.subscribed = true;
|
|
112
117
|
this.backend.subscribe(async (result) => {
|
|
113
|
-
if (!result.ok) {
|
|
118
|
+
if (!result.ok || !this.running) {
|
|
114
119
|
return;
|
|
115
120
|
}
|
|
116
121
|
|
|
@@ -134,7 +139,7 @@ export class Worker {
|
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
/*
|
|
137
|
-
*
|
|
142
|
+
* Claim and process a workflow run for the given worker ID. Do not await the
|
|
138
143
|
* processing here to avoid blocking the caller.
|
|
139
144
|
* Returns the claimed workflow run, or null if none was available.
|
|
140
145
|
*/
|
|
@@ -203,8 +208,13 @@ export class Worker {
|
|
|
203
208
|
workflowVersion: execution.workflowRun.version,
|
|
204
209
|
workerId: execution.workerId,
|
|
205
210
|
retryPolicy: workflow.spec.retryPolicy,
|
|
211
|
+
signal: execution.signal,
|
|
206
212
|
});
|
|
207
213
|
} catch (error) {
|
|
214
|
+
if (execution.signal.aborted) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
208
218
|
// specifically for unexpected errors in the execution wrapper itself, not
|
|
209
219
|
// for business logic errors (those are handled inside executeWorkflow)
|
|
210
220
|
console.error(
|
|
@@ -233,6 +243,7 @@ class WorkflowExecution {
|
|
|
233
243
|
workflowRun: WorkflowRun;
|
|
234
244
|
workerId: string;
|
|
235
245
|
private heartbeatTimer: NodeJS.Timeout | null = null;
|
|
246
|
+
private abortController = new AbortController();
|
|
236
247
|
|
|
237
248
|
constructor(options: WorkflowExecutionOptions) {
|
|
238
249
|
this.backend = options.backend;
|
|
@@ -240,6 +251,14 @@ class WorkflowExecution {
|
|
|
240
251
|
this.workerId = options.workerId;
|
|
241
252
|
}
|
|
242
253
|
|
|
254
|
+
/**
|
|
255
|
+
* 외부에서 abort 여부를 확인할 수 있는 signal입니다.
|
|
256
|
+
* heartbeat 실패 시 abort됩니다.
|
|
257
|
+
*/
|
|
258
|
+
get signal(): AbortSignal {
|
|
259
|
+
return this.abortController.signal;
|
|
260
|
+
}
|
|
261
|
+
|
|
243
262
|
/**
|
|
244
263
|
* Start the heartbeat loop for this execution, heartbeating at half the lease
|
|
245
264
|
* duration.
|
|
@@ -255,8 +274,10 @@ class WorkflowExecution {
|
|
|
255
274
|
workerId: this.workerId,
|
|
256
275
|
leaseDurationMs,
|
|
257
276
|
})
|
|
258
|
-
.catch((
|
|
259
|
-
|
|
277
|
+
.catch((_error: unknown) => {
|
|
278
|
+
// lease 연장 실패에는 heartbeat를 중단하고 abort 신호를 보냅니다.
|
|
279
|
+
this.abortController.abort();
|
|
280
|
+
this.stopHeartbeat();
|
|
260
281
|
});
|
|
261
282
|
}, heartbeatIntervalMs);
|
|
262
283
|
}
|
package/src/workflow.test.ts
CHANGED
package/src/workflow.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type
|
|
3
|
-
import type
|
|
1
|
+
import { type RetryPolicy } from "./core/retry";
|
|
2
|
+
import { type StandardSchemaV1 } from "./core/schema";
|
|
3
|
+
import { type WorkflowFunction } from "./execution";
|
|
4
4
|
|
|
5
5
|
export interface WorkflowSpec<Input, Output, RawInput> {
|
|
6
6
|
/** The name of the workflow. */
|