@sonamu-kit/tasks 0.1.2 → 0.2.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 (46) hide show
  1. package/dist/backend.d.ts +4 -0
  2. package/dist/backend.d.ts.map +1 -1
  3. package/dist/backend.js.map +1 -1
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +3 -1
  6. package/dist/client.js.map +1 -1
  7. package/dist/core/retry.d.ts +35 -19
  8. package/dist/core/retry.d.ts.map +1 -1
  9. package/dist/core/retry.js +50 -14
  10. package/dist/core/retry.js.map +1 -1
  11. package/dist/core/retry.test.js +172 -11
  12. package/dist/core/retry.test.js.map +1 -1
  13. package/dist/database/backend.d.ts.map +1 -1
  14. package/dist/database/backend.js +114 -5
  15. package/dist/database/backend.js.map +1 -1
  16. package/dist/database/backend.testsuite.d.ts.map +1 -1
  17. package/dist/database/backend.testsuite.js +106 -0
  18. package/dist/database/backend.testsuite.js.map +1 -1
  19. package/dist/execution.d.ts +2 -0
  20. package/dist/execution.d.ts.map +1 -1
  21. package/dist/execution.js +17 -3
  22. package/dist/execution.js.map +1 -1
  23. package/dist/execution.test.js +104 -0
  24. package/dist/execution.test.js.map +1 -1
  25. package/dist/internal.d.ts +2 -1
  26. package/dist/internal.d.ts.map +1 -1
  27. package/dist/internal.js +1 -1
  28. package/dist/internal.js.map +1 -1
  29. package/dist/worker.d.ts.map +1 -1
  30. package/dist/worker.js +2 -1
  31. package/dist/worker.js.map +1 -1
  32. package/dist/workflow.d.ts +3 -0
  33. package/dist/workflow.d.ts.map +1 -1
  34. package/dist/workflow.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/backend.ts +4 -0
  37. package/src/client.ts +2 -0
  38. package/src/core/retry.test.ts +180 -11
  39. package/src/core/retry.ts +95 -19
  40. package/src/database/backend.testsuite.ts +119 -0
  41. package/src/database/backend.ts +133 -5
  42. package/src/execution.test.ts +115 -0
  43. package/src/execution.ts +18 -2
  44. package/src/internal.ts +21 -1
  45. package/src/worker.ts +1 -0
  46. package/src/workflow.ts +3 -0
@@ -20,7 +20,7 @@ import {
20
20
  type PaginatedResponse,
21
21
  type SleepWorkflowRunParams,
22
22
  } from "../backend";
23
- import { DEFAULT_RETRY_POLICY } from "../core/retry";
23
+ import { mergeRetryPolicy, type SerializableRetryPolicy } from "../core/retry";
24
24
  import type { StepAttempt } from "../core/step";
25
25
  import type { WorkflowRun } from "../core/workflow";
26
26
  import { DEFAULT_SCHEMA, migrate } from "./base";
@@ -38,6 +38,7 @@ interface BackendPostgresOptions {
38
38
  }
39
39
 
40
40
  const logger = getLogger(["sonamu", "internal", "tasks"]);
41
+ const queryLogger = getLogger(["sonamu", "internal", "tasks", "query"]);
41
42
 
42
43
  /**
43
44
  * Manages a connection to a Postgres database for workflow operations.
@@ -54,6 +55,12 @@ export class BackendPostgres implements Backend {
54
55
  private get knex(): Knex {
55
56
  if (!this._knex) {
56
57
  this._knex = knex(this.config);
58
+ this._knex.on("query", (query) => {
59
+ queryLogger.debug("SQL: {query}, Values: {bindings}", {
60
+ query: query.sql,
61
+ bindings: query.bindings,
62
+ });
63
+ });
57
64
  }
58
65
 
59
66
  return this._knex;
@@ -155,6 +162,17 @@ export class BackendPostgres implements Backend {
155
162
  throw new Error("Backend not initialized");
156
163
  }
157
164
 
165
+ logger.info("Creating workflow run: {workflowName}:{version}", {
166
+ workflowName: params.workflowName,
167
+ version: params.version,
168
+ });
169
+
170
+ // config에 retryPolicy를 포함시킵니다.
171
+ const configWithRetryPolicy = {
172
+ ...(typeof params.config === "object" && params.config !== null ? params.config : {}),
173
+ retryPolicy: params.retryPolicy ?? undefined,
174
+ };
175
+
158
176
  const qb = this.knex
159
177
  .withSchema(DEFAULT_SCHEMA)
160
178
  .table("workflow_runs")
@@ -165,7 +183,7 @@ export class BackendPostgres implements Backend {
165
183
  version: params.version,
166
184
  status: "pending",
167
185
  idempotency_key: params.idempotencyKey,
168
- config: params.config,
186
+ config: JSON.stringify(configWithRetryPolicy),
169
187
  context: params.context,
170
188
  input: params.input,
171
189
  attempts: 0,
@@ -190,6 +208,7 @@ export class BackendPostgres implements Backend {
190
208
  throw new Error("Backend not initialized");
191
209
  }
192
210
 
211
+ logger.info("Getting workflow run: {workflowRunId}", { workflowRunId: params.workflowRunId });
193
212
  const workflowRun = await this.knex
194
213
  .withSchema(DEFAULT_SCHEMA)
195
214
  .table("workflow_runs")
@@ -228,6 +247,10 @@ export class BackendPostgres implements Backend {
228
247
  throw new Error("Backend not initialized");
229
248
  }
230
249
 
250
+ logger.info("Listing workflow runs: {after}, {before}", {
251
+ after: params.after,
252
+ before: params.before,
253
+ });
231
254
  const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
232
255
  const { after, before } = params;
233
256
 
@@ -275,6 +298,10 @@ export class BackendPostgres implements Backend {
275
298
  throw new Error("Backend not initialized");
276
299
  }
277
300
 
301
+ logger.info("Claiming workflow run: {workerId}, {leaseDurationMs}", {
302
+ workerId: params.workerId,
303
+ leaseDurationMs: params.leaseDurationMs,
304
+ });
278
305
  const claimed = await this.knex
279
306
  .with("expired", (qb) =>
280
307
  qb
@@ -335,6 +362,11 @@ export class BackendPostgres implements Backend {
335
362
  throw new Error("Backend not initialized");
336
363
  }
337
364
 
365
+ logger.info("Extending workflow run lease: {workflowRunId}, {workerId}, {leaseDurationMs}", {
366
+ workflowRunId: params.workflowRunId,
367
+ workerId: params.workerId,
368
+ leaseDurationMs: params.leaseDurationMs,
369
+ });
338
370
  const [updated] = await this.knex
339
371
  .withSchema(DEFAULT_SCHEMA)
340
372
  .table("workflow_runs")
@@ -361,6 +393,12 @@ export class BackendPostgres implements Backend {
361
393
  throw new Error("Backend not initialized");
362
394
  }
363
395
 
396
+ logger.info("Sleeping workflow run: {workflowRunId}, {workerId}, {availableAt}", {
397
+ workflowRunId: params.workflowRunId,
398
+ workerId: params.workerId,
399
+ availableAt: params.availableAt,
400
+ });
401
+
364
402
  // 'succeeded' status is deprecated
365
403
  const [updated] = await this.knex
366
404
  .withSchema(DEFAULT_SCHEMA)
@@ -390,6 +428,12 @@ export class BackendPostgres implements Backend {
390
428
  throw new Error("Backend not initialized");
391
429
  }
392
430
 
431
+ logger.info("Completing workflow run: {workflowRunId}, {workerId}, {output}", {
432
+ workflowRunId: params.workflowRunId,
433
+ workerId: params.workerId,
434
+ output: params.output,
435
+ });
436
+
393
437
  const [updated] = await this.knex
394
438
  .withSchema(DEFAULT_SCHEMA)
395
439
  .table("workflow_runs")
@@ -421,8 +465,60 @@ export class BackendPostgres implements Backend {
421
465
  throw new Error("Backend not initialized");
422
466
  }
423
467
 
424
- const { workflowRunId, error } = params;
425
- const { initialIntervalMs, backoffCoefficient, maximumIntervalMs } = DEFAULT_RETRY_POLICY;
468
+ const { workflowRunId, error, forceComplete, customDelayMs } = params;
469
+
470
+ logger.info("Failing workflow run: {workflowRunId}, {workerId}, {error}", {
471
+ workflowRunId: params.workflowRunId,
472
+ workerId: params.workerId,
473
+ error: params.error,
474
+ });
475
+
476
+ const workflowRun = await this.knex
477
+ .withSchema(DEFAULT_SCHEMA)
478
+ .table("workflow_runs")
479
+ .where("namespace_id", this.namespaceId)
480
+ .where("id", workflowRunId)
481
+ .first();
482
+
483
+ if (!workflowRun) {
484
+ throw new Error("Workflow run not found");
485
+ }
486
+
487
+ const config =
488
+ typeof workflowRun.config === "string" ? JSON.parse(workflowRun.config) : workflowRun.config;
489
+ const savedRetryPolicy: SerializableRetryPolicy | undefined = config?.retryPolicy;
490
+ const retryPolicy = mergeRetryPolicy(savedRetryPolicy);
491
+
492
+ const { initialIntervalMs, backoffCoefficient, maximumIntervalMs, maxAttempts } = retryPolicy;
493
+
494
+ const currentAttempts = workflowRun.attempts ?? 0;
495
+ const shouldForceComplete = forceComplete || currentAttempts >= maxAttempts;
496
+
497
+ if (shouldForceComplete) {
498
+ const [updated] = await this.knex
499
+ .withSchema(DEFAULT_SCHEMA)
500
+ .table("workflow_runs")
501
+ .where("namespace_id", this.namespaceId)
502
+ .where("id", workflowRunId)
503
+ .where("status", "running")
504
+ .where("worker_id", params.workerId)
505
+ .update({
506
+ status: "failed",
507
+ available_at: null,
508
+ finished_at: this.knex.fn.now(),
509
+ error: JSON.stringify(error),
510
+ worker_id: null,
511
+ started_at: null,
512
+ updated_at: this.knex.fn.now(),
513
+ })
514
+ .returning("*");
515
+
516
+ if (!updated) {
517
+ logger.error("Failed to mark workflow run failed: {params}", { params });
518
+ throw new Error("Failed to mark workflow run failed");
519
+ }
520
+ return updated;
521
+ }
426
522
 
427
523
  // this beefy query updates a workflow's status, available_at, and
428
524
  // finished_at based on the workflow's deadline and retry policy
@@ -430,7 +526,9 @@ export class BackendPostgres implements Backend {
430
526
  // if the next retry would exceed the deadline, the run is marked as
431
527
  // 'failed' and finalized, otherwise, the run is rescheduled with an updated
432
528
  // 'available_at' timestamp for the next retry
433
- const retryIntervalExpr = `LEAST(${initialIntervalMs} * POWER(${backoffCoefficient}, "attempts" - 1), ${maximumIntervalMs}) * INTERVAL '1 millisecond'`;
529
+ const retryIntervalExpr = customDelayMs
530
+ ? `${customDelayMs} * INTERVAL '1 millisecond'`
531
+ : `LEAST(${initialIntervalMs} * POWER(${backoffCoefficient}, "attempts" - 1), ${maximumIntervalMs}) * INTERVAL '1 millisecond'`;
434
532
  const deadlineExceededCondition = `"deadline_at" IS NOT NULL AND NOW() + (${retryIntervalExpr}) >= "deadline_at"`;
435
533
 
436
534
  const [updated] = await this.knex
@@ -470,6 +568,8 @@ export class BackendPostgres implements Backend {
470
568
  throw new Error("Backend not initialized");
471
569
  }
472
570
 
571
+ logger.info("Canceling workflow run: {workflowRunId}", { workflowRunId: params.workflowRunId });
572
+
473
573
  const [updated] = await this.knex
474
574
  .withSchema(DEFAULT_SCHEMA)
475
575
  .table("workflow_runs")
@@ -523,6 +623,12 @@ export class BackendPostgres implements Backend {
523
623
  throw new Error("Backend not initialized");
524
624
  }
525
625
 
626
+ logger.info("Creating step attempt: {workflowRunId}, {stepName}, {kind}", {
627
+ workflowRunId: params.workflowRunId,
628
+ stepName: params.stepName,
629
+ kind: params.kind,
630
+ });
631
+
526
632
  const [stepAttempt] = await this.knex
527
633
  .withSchema(DEFAULT_SCHEMA)
528
634
  .table("step_attempts")
@@ -554,6 +660,8 @@ export class BackendPostgres implements Backend {
554
660
  throw new Error("Backend not initialized");
555
661
  }
556
662
 
663
+ logger.info("Getting step attempt: {stepAttemptId}", { stepAttemptId: params.stepAttemptId });
664
+
557
665
  const stepAttempt = await this.knex
558
666
  .withSchema(DEFAULT_SCHEMA)
559
667
  .table("step_attempts")
@@ -569,6 +677,12 @@ export class BackendPostgres implements Backend {
569
677
  throw new Error("Backend not initialized");
570
678
  }
571
679
 
680
+ logger.info("Listing step attempts: {workflowRunId}, {after}, {before}", {
681
+ workflowRunId: params.workflowRunId,
682
+ after: params.after,
683
+ before: params.before,
684
+ });
685
+
572
686
  const limit = params.limit ?? DEFAULT_PAGINATION_PAGE_SIZE;
573
687
  const { after, before } = params;
574
688
 
@@ -653,11 +767,18 @@ export class BackendPostgres implements Backend {
653
767
  };
654
768
  }
655
769
 
770
+ // NOTE: 실제 서비스에서 이게 안 되는 것 같은데, 쿼리 등을 체크할 필요가 있음.
656
771
  async completeStepAttempt(params: CompleteStepAttemptParams): Promise<StepAttempt> {
657
772
  if (!this.initialized) {
658
773
  throw new Error("Backend not initialized");
659
774
  }
660
775
 
776
+ logger.info("Marking step attempt as completed: {workflowRunId}, {stepAttemptId}, {workerId}", {
777
+ workflowRunId: params.workflowRunId,
778
+ stepAttemptId: params.stepAttemptId,
779
+ workerId: params.workerId,
780
+ });
781
+
661
782
  const [updated] = await this.knex
662
783
  .withSchema(DEFAULT_SCHEMA)
663
784
  .table("step_attempts as sa")
@@ -692,6 +813,13 @@ export class BackendPostgres implements Backend {
692
813
  throw new Error("Backend not initialized");
693
814
  }
694
815
 
816
+ logger.info("Marking step attempt as failed: {workflowRunId}, {stepAttemptId}, {workerId}", {
817
+ workflowRunId: params.workflowRunId,
818
+ stepAttemptId: params.stepAttemptId,
819
+ workerId: params.workerId,
820
+ });
821
+ logger.info("Error: {error.message}", { error: params.error.message });
822
+
695
823
  const [updated] = await this.knex
696
824
  .withSchema(DEFAULT_SCHEMA)
697
825
  .table("step_attempts as sa")
@@ -505,6 +505,121 @@ describe("executeWorkflow", () => {
505
505
  });
506
506
  });
507
507
 
508
+ describe("executeWorkflow with dynamic retryPolicy", () => {
509
+ let backend: BackendPostgres;
510
+
511
+ beforeAll(async () => {
512
+ backend = new BackendPostgres(KNEX_GLOBAL_CONFIG, {
513
+ namespaceId: randomUUID(),
514
+ runMigrations: false,
515
+ });
516
+ await backend.initialize();
517
+ });
518
+
519
+ afterAll(async () => {
520
+ await backend.stop();
521
+ });
522
+
523
+ test("calls failWorkflowRun with forceComplete=true when shouldRetry returns false", async () => {
524
+ const client = new OpenWorkflow({ backend });
525
+
526
+ // shouldRetry가 false를 반환하는 retryPolicy
527
+ const workflow = client.defineWorkflow(
528
+ {
529
+ name: "dynamic-retry-false",
530
+ retryPolicy: {
531
+ maxAttempts: 10, // 정적으로는 10번까지 허용하지만
532
+ shouldRetry: () => ({ shouldRetry: false, delayMs: 0 }), // 동적으로 즉시 거부
533
+ },
534
+ },
535
+ () => {
536
+ throw new Error("Intentional failure");
537
+ },
538
+ );
539
+
540
+ const worker = client.newWorker();
541
+ const handle = await workflow.run();
542
+ await worker.tick();
543
+ await sleep(100);
544
+
545
+ // shouldRetry가 false를 반환했으므로 즉시 failed 상태가 되어야 합니다
546
+ const workflowRun = await backend.getWorkflowRun({
547
+ workflowRunId: handle.workflowRun.id,
548
+ });
549
+ expect(workflowRun?.status).toBe("failed");
550
+ expect(workflowRun?.attempts).toBe(1); // 한 번만 시도하고 종료
551
+ });
552
+
553
+ test("calls failWorkflowRun with forceComplete=false when shouldRetry returns true", async () => {
554
+ const client = new OpenWorkflow({ backend });
555
+
556
+ // shouldRetry가 true를 반환하는 retryPolicy
557
+ const workflow = client.defineWorkflow(
558
+ {
559
+ name: "dynamic-retry-true",
560
+ retryPolicy: {
561
+ maxAttempts: 2,
562
+ shouldRetry: () => ({ shouldRetry: true, delayMs: 1000 }),
563
+ },
564
+ },
565
+ () => {
566
+ throw new Error("Intentional failure");
567
+ },
568
+ );
569
+
570
+ const worker = client.newWorker();
571
+ const handle = await workflow.run();
572
+ await worker.tick();
573
+ await sleep(100);
574
+
575
+ // shouldRetry가 true를 반환했으므로 pending 상태로 재시도 대기
576
+ const workflowRun = await backend.getWorkflowRun({
577
+ workflowRunId: handle.workflowRun.id,
578
+ });
579
+ expect(workflowRun?.status).toBe("pending");
580
+ expect(workflowRun?.attempts).toBe(1);
581
+ });
582
+
583
+ test("receives correct error and attempt number in shouldRetry function", async () => {
584
+ const client = new OpenWorkflow({ backend });
585
+
586
+ let receivedError: unknown = null;
587
+ let receivedAttempt: number | null = null;
588
+
589
+ const workflow = client.defineWorkflow(
590
+ {
591
+ name: "dynamic-retry-params",
592
+ retryPolicy: {
593
+ maxAttempts: 10,
594
+ shouldRetry: (error, attempt) => {
595
+ receivedError = error;
596
+ receivedAttempt = attempt;
597
+ return { shouldRetry: false, delayMs: 0 };
598
+ },
599
+ },
600
+ },
601
+ () => {
602
+ throw new Error("Test error message");
603
+ },
604
+ );
605
+
606
+ const worker = client.newWorker();
607
+ const handle = await workflow.run();
608
+ await worker.tick();
609
+ await sleep(100);
610
+
611
+ // shouldRetry 함수가 올바른 파라미터를 받았는지 확인
612
+ expect(receivedError).not.toBeNull();
613
+ expect((receivedError as { message?: string }).message).toBe("Test error message");
614
+ expect(receivedAttempt).toBe(1); // 첫 번째 시도 후이므로 1
615
+
616
+ const workflowRun = await backend.getWorkflowRun({
617
+ workflowRunId: handle.workflowRun.id,
618
+ });
619
+ expect(workflowRun?.status).toBe("failed");
620
+ });
621
+ });
622
+
508
623
  function sleep(ms: number): Promise<void> {
509
624
  return new Promise((resolve) => setTimeout(resolve, ms));
510
625
  }
package/src/execution.ts CHANGED
@@ -2,6 +2,7 @@ import type { Backend } from "./backend";
2
2
  import type { DurationString } from "./core/duration";
3
3
  import { serializeError } from "./core/error";
4
4
  import type { JsonValue } from "./core/json";
5
+ import { isDynamicRetryPolicy, type RetryPolicy } from "./core/retry";
5
6
  import type { StepAttempt, StepAttemptCache } from "./core/step";
6
7
  import {
7
8
  addToStepAttemptCache,
@@ -186,6 +187,7 @@ export interface ExecuteWorkflowParams {
186
187
  workflowFn: WorkflowFunction<unknown, unknown>;
187
188
  workflowVersion: string | null;
188
189
  workerId: string;
190
+ retryPolicy?: RetryPolicy;
189
191
  }
190
192
 
191
193
  /**
@@ -198,7 +200,7 @@ export interface ExecuteWorkflowParams {
198
200
  * @param params - The execution parameters
199
201
  */
200
202
  export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>): Promise<void> {
201
- const { backend, workflowRun, workflowFn, workflowVersion, workerId } = params;
203
+ const { backend, workflowRun, workflowFn, workflowVersion, workerId, retryPolicy } = params;
202
204
 
203
205
  try {
204
206
  // load all pages of step history
@@ -281,11 +283,25 @@ export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>):
281
283
  return;
282
284
  }
283
285
 
284
- // mark failure
286
+ // claimWorkflowRun에서 이미 attempts가 증가된 상태입니다.
287
+ let forceComplete = false;
288
+ let customDelayMs: number | undefined;
289
+ if (retryPolicy && isDynamicRetryPolicy(retryPolicy)) {
290
+ const serializedError = serializeError(error);
291
+ const decision = retryPolicy.shouldRetry(serializedError, workflowRun.attempts ?? 1);
292
+ if (!decision.shouldRetry) {
293
+ forceComplete = true;
294
+ } else {
295
+ customDelayMs = decision.delayMs;
296
+ }
297
+ }
298
+
285
299
  await backend.failWorkflowRun({
286
300
  workflowRunId: workflowRun.id,
287
301
  workerId,
288
302
  error: serializeError(error),
303
+ forceComplete,
304
+ customDelayMs,
289
305
  });
290
306
  }
291
307
  }
package/src/internal.ts CHANGED
@@ -3,7 +3,27 @@ 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 { DEFAULT_RETRY_POLICY } from "./core/retry";
6
+ export {
7
+ DEFAULT_RETRY_POLICY,
8
+ mergeRetryPolicy,
9
+ serializeRetryPolicy,
10
+ shouldRetryByPolicy,
11
+ calculateRetryDelayMs,
12
+ shouldRetry,
13
+ isDynamicRetryPolicy,
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,
26
+ } from "./core/retry";
7
27
  export type { StandardSchemaV1 } from "./core/schema";
8
28
  export type { StepAttempt } from "./core/step";
9
29
  export type { SchemaInput, SchemaOutput, WorkflowRun } from "./core/workflow";
package/src/worker.ts CHANGED
@@ -202,6 +202,7 @@ export class Worker {
202
202
  workflowFn: workflow.fn,
203
203
  workflowVersion: execution.workflowRun.version,
204
204
  workerId: execution.workerId,
205
+ retryPolicy: workflow.spec.retryPolicy,
205
206
  });
206
207
  } catch (error) {
207
208
  // specifically for unexpected errors in the execution wrapper itself, not
package/src/workflow.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { RetryPolicy } from "./core/retry";
1
2
  import type { StandardSchemaV1 } from "./core/schema";
2
3
  import type { WorkflowFunction } from "./execution";
3
4
 
@@ -8,6 +9,8 @@ export interface WorkflowSpec<Input, Output, RawInput> {
8
9
  readonly version?: string;
9
10
  /** The schema used to validate inputs. */
10
11
  readonly schema?: StandardSchemaV1<RawInput, Input>;
12
+ /** The retry policy for the workflow. */
13
+ readonly retryPolicy?: RetryPolicy;
11
14
  /** Phantom type carrier - won't exist at runtime. */
12
15
  readonly __types?: {
13
16
  output: Output;