@shivaedev/jobs 0.29.1 → 0.30.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 (81) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +26 -5
  3. package/dist/server/adapters/clientErrorLogger.d.ts +3 -0
  4. package/dist/server/adapters/clientErrorLogger.d.ts.map +1 -0
  5. package/dist/server/adapters/clientErrorLogger.js +9 -0
  6. package/dist/server/adapters/clientErrorLogger.js.map +1 -0
  7. package/dist/server/adapters/clientErrorReporter.d.ts +1 -1
  8. package/dist/server/adapters/clientErrorReporter.d.ts.map +1 -1
  9. package/dist/server/adapters/clientErrorReporter.js +3 -3
  10. package/dist/server/adapters/clientErrorReporter.js.map +1 -1
  11. package/dist/server/adapters/registerWorker.d.ts +2 -2
  12. package/dist/server/adapters/registerWorker.d.ts.map +1 -1
  13. package/dist/server/adapters/registerWorker.js +22 -7
  14. package/dist/server/adapters/registerWorker.js.map +1 -1
  15. package/dist/server/definition.d.ts +3 -3
  16. package/dist/server/definition.d.ts.map +1 -1
  17. package/dist/server/definition.js.map +1 -1
  18. package/dist/server/internal/batchHandler.d.ts +1 -1
  19. package/dist/server/internal/batchHandler.d.ts.map +1 -1
  20. package/dist/server/internal/batchHandler.js +3 -3
  21. package/dist/server/internal/batchHandler.js.map +1 -1
  22. package/dist/server/internal/jobControl.d.ts.map +1 -1
  23. package/dist/server/internal/jobControl.js +17 -5
  24. package/dist/server/internal/jobControl.js.map +1 -1
  25. package/dist/server/internal/register.d.ts +3 -2
  26. package/dist/server/internal/register.d.ts.map +1 -1
  27. package/dist/server/internal/register.js +20 -31
  28. package/dist/server/internal/register.js.map +1 -1
  29. package/dist/server/internal/registerReleasedJobs.d.ts +2 -1
  30. package/dist/server/internal/registerReleasedJobs.d.ts.map +1 -1
  31. package/dist/server/internal/registerReleasedJobs.js +3 -3
  32. package/dist/server/internal/registerReleasedJobs.js.map +1 -1
  33. package/dist/server/internal/retryWake.d.ts +1 -1
  34. package/dist/server/internal/retryWake.d.ts.map +1 -1
  35. package/dist/server/internal/retryWake.js +4 -2
  36. package/dist/server/internal/retryWake.js.map +1 -1
  37. package/dist/server/internal/runQueueWorker.d.ts +4 -2
  38. package/dist/server/internal/runQueueWorker.d.ts.map +1 -1
  39. package/dist/server/internal/runQueueWorker.js.map +1 -1
  40. package/dist/server/internal/testingClient.d.ts.map +1 -1
  41. package/dist/server/internal/testingClient.js +4 -1
  42. package/dist/server/internal/testingClient.js.map +1 -1
  43. package/dist/server/internal/testingIdle.d.ts.map +1 -1
  44. package/dist/server/internal/testingIdle.js +9 -3
  45. package/dist/server/internal/testingIdle.js.map +1 -1
  46. package/dist/server/internal/testingRegistrations.d.ts +4 -0
  47. package/dist/server/internal/testingRegistrations.d.ts.map +1 -0
  48. package/dist/server/internal/testingRegistrations.js +8 -0
  49. package/dist/server/internal/testingRegistrations.js.map +1 -0
  50. package/dist/server/internal/testingSnapshot.d.ts.map +1 -1
  51. package/dist/server/internal/testingSnapshot.js +5 -2
  52. package/dist/server/internal/testingSnapshot.js.map +1 -1
  53. package/dist/server/jobModule.d.ts +7 -0
  54. package/dist/server/jobModule.d.ts.map +1 -0
  55. package/dist/server/jobModule.js +13 -0
  56. package/dist/server/jobModule.js.map +1 -0
  57. package/dist/server/service.d.ts.map +1 -1
  58. package/dist/server/service.js +10 -7
  59. package/dist/server/service.js.map +1 -1
  60. package/dist/server/testing.d.ts +3 -2
  61. package/dist/server/testing.d.ts.map +1 -1
  62. package/dist/server/testing.js +5 -0
  63. package/dist/server/testing.js.map +1 -1
  64. package/package.json +9 -9
  65. package/src/server/adapters/clientErrorLogger.ts +9 -0
  66. package/src/server/adapters/clientErrorReporter.ts +3 -3
  67. package/src/server/adapters/registerWorker.ts +30 -11
  68. package/src/server/definition.ts +4 -3
  69. package/src/server/internal/batchHandler.ts +3 -3
  70. package/src/server/internal/jobControl.ts +23 -6
  71. package/src/server/internal/register.ts +38 -47
  72. package/src/server/internal/registerReleasedJobs.ts +5 -4
  73. package/src/server/internal/retryWake.ts +4 -2
  74. package/src/server/internal/runQueueWorker.ts +8 -2
  75. package/src/server/internal/testingClient.ts +4 -1
  76. package/src/server/internal/testingIdle.ts +8 -3
  77. package/src/server/internal/testingRegistrations.ts +10 -0
  78. package/src/server/internal/testingSnapshot.ts +5 -2
  79. package/src/server/jobModule.ts +18 -0
  80. package/src/server/service.ts +26 -22
  81. package/src/server/testing.ts +13 -2
@@ -1,5 +1,5 @@
1
1
  import { Effect, Fiber, Scope } from "effect";
2
- import type { Job, WorkOptions } from "pg-boss";
2
+ import type { Job, StopOptions, WorkOptions } from "pg-boss";
3
3
  import type { PgBossClient } from "#client.ts";
4
4
  import { toPgBossError } from "#error.ts";
5
5
  import { batchHandler } from "#internal/batchHandler.ts";
@@ -15,19 +15,26 @@ export const registerWorker = Effect.fnUntraced(function* <R>(
15
15
  scope: Scope.Scope,
16
16
  schema: string,
17
17
  maxDeferrals: number,
18
- graceful: boolean,
18
+ stop?: StopOptions,
19
19
  ) {
20
20
  const execute = Effect.runPromiseWith(yield* Effect.context<R>());
21
- const fibers = yield* Scope.fork(scope);
21
+ const workerScope = yield* Scope.fork(scope);
22
+ const fibers = yield* Scope.fork(workerScope);
22
23
  function runJob(job: Job<unknown>, deadline: number) {
23
24
  return traceJob(deferrableAttempt(run(job, deadline), client, schema, job, maxDeferrals), job, name);
24
25
  }
25
- function afterFailure(attempt: Job<unknown>) {
26
- return retryWake(client, name, attempt.id, registration).pipe(Effect.forkIn(fibers), Effect.asVoid);
26
+ function afterRetry(attempt: Job<unknown>) {
27
+ return retryWake(client, schema, name, attempt.id, registration).pipe(Effect.forkIn(fibers), Effect.asVoid);
27
28
  }
28
29
  const handle = yield* Effect.try({
29
30
  catch: (error) => toPgBossError("register", error, name),
30
- try: () => batchHandler(client, name, schema, runJob, afterFailure),
31
+ try: () => {
32
+ const unsupported = (["perJobResults", "transactional"] as const).filter((mode) => options[mode] === true);
33
+ if (unsupported.length > 0) {
34
+ throw new TypeError(`Unsupported worker callback modes: ${unsupported.join(", ")}. Omit these options or set them to false.`);
35
+ }
36
+ return batchHandler(client, name, schema, runJob, afterRetry);
37
+ },
31
38
  });
32
39
  const registration: Promise<string> = yield* Effect.try({
33
40
  catch: (error) => toPgBossError("register", error, name),
@@ -37,12 +44,24 @@ export const registerWorker = Effect.fnUntraced(function* <R>(
37
44
  ),
38
45
  });
39
46
  const id = yield* Effect.tryPromise({ catch: (error) => toPgBossError("register", error, name), try: () => registration });
47
+ const graceful = stop?.graceful !== false;
48
+ const timeout = stop?.timeout ?? 30_000;
49
+ const drain = Effect.tryPromise({
50
+ catch: (error) => toPgBossError("stop", error, name),
51
+ try: () => client.offWork(name, { id, wait: graceful }),
52
+ });
40
53
  yield* Scope.addFinalizer(
41
- scope,
42
- Effect.tryPromise({
43
- catch: (error) => toPgBossError("stop", error, name),
44
- try: () => client.offWork(name, { id, wait: graceful }),
45
- }).pipe(Effect.catch(Effect.logError)),
54
+ workerScope,
55
+ (graceful
56
+ ? drain.pipe(
57
+ Effect.timeoutOrElse({
58
+ duration: timeout,
59
+ orElse: () => Effect.logWarning(`pg-boss worker drain timed out for queue ${name} after ${timeout}ms; interrupting in-flight work`),
60
+ }),
61
+ Effect.interruptible,
62
+ )
63
+ : drain
64
+ ).pipe(Effect.catch(Effect.logError)),
46
65
  );
47
66
  return id;
48
67
  });
@@ -48,9 +48,10 @@ export interface QueueWorker<TPayload extends JobPayloadSchema = JobPayloadSchem
48
48
  readonly queue: QueueDefinition<string, TPayload>;
49
49
  }
50
50
 
51
- export interface JobRegistration {
52
- readonly _tag: "QueueDefinition" | "QueueWorker" | "ScheduledWorker";
53
- }
51
+ export type JobRegistration<TRequirements = unknown> =
52
+ | QueueDefinition
53
+ | QueueWorker<JobPayloadSchema & { readonly DecodingServices: TRequirements }, TRequirements>
54
+ | ScheduledWorker<TRequirements>;
54
55
 
55
56
  export interface ScheduleDefinition<TName extends string = string> {
56
57
  readonly _tag: "ScheduleDefinition";
@@ -52,7 +52,7 @@ export function batchHandler<R>(
52
52
  name: string,
53
53
  schema: string,
54
54
  run: (job: Job<unknown>, deadline: number) => Effect.Effect<unknown, unknown, R>,
55
- afterFailure: (job: Job<unknown>) => Effect.Effect<void, unknown> = () => Effect.void,
55
+ afterRetry: (job: Job<unknown>) => Effect.Effect<void, unknown> = () => Effect.void,
56
56
  ) {
57
57
  const complete = client.complete?.bind(client);
58
58
  const fail = client.fail?.bind(client);
@@ -73,8 +73,8 @@ export function batchHandler<R>(
73
73
  return "interrupted";
74
74
  }
75
75
  yield* settleAttempt(complete, fail, name, job, exit);
76
- if (Exit.isFailure(exit)) {
77
- yield* afterFailure(job);
76
+ if (Exit.isFailure(exit) || exit.value === deferredAttempt) {
77
+ yield* afterRetry(job);
78
78
  }
79
79
  return "settled";
80
80
  });
@@ -1,10 +1,21 @@
1
- import { Effect, Option } from "effect";
1
+ import { Effect, Option, Redacted, Schema } from "effect";
2
2
  import { redact } from "@shivaedev/errors/redact.ts";
3
3
  import type { PgBossClient } from "#client.ts";
4
- import { toPgBossError } from "#error.ts";
4
+ import { PgBossPayloadError, toPgBossError } from "#error.ts";
5
+ import { deferralOf } from "#internal/deferral.ts";
5
6
  import { decodeStoredData } from "#internal/storedRows.ts";
7
+ import { JobFailure } from "#JobFailure.ts";
6
8
  import type { PgBossService } from "#service.ts";
7
9
 
10
+ const StoredFailure = Schema.Union([JobFailure, Schema.Struct({ value: JobFailure })]);
11
+
12
+ function decodeFailure(queue: string, output: unknown) {
13
+ return Schema.decodeUnknownEffect(StoredFailure)(output).pipe(
14
+ Effect.map((failure) => ("value" in failure ? failure.value : failure)),
15
+ Effect.mapError((error) => new PgBossPayloadError({ direction: "decode", original: Redacted.make(error), queue })),
16
+ );
17
+ }
18
+
8
19
  export function getJobFor(client: PgBossClient): PgBossService["getJob"] {
9
20
  return (queue, id) =>
10
21
  Effect.tryPromise({
@@ -16,10 +27,16 @@ export function getJobFor(client: PgBossClient): PgBossService["getJob"] {
16
27
  return (await client.findJobs(queue.name, { id }))[0];
17
28
  },
18
29
  }).pipe(
19
- Effect.flatMap((job) =>
20
- job === undefined
21
- ? Effect.succeed(Option.none())
22
- : decodeStoredData(queue, job.data).pipe(Effect.map((data) => Option.some({ ...job, data: redact(data) }))),
30
+ Effect.flatMap(
31
+ Effect.fnUntraced(function* (job) {
32
+ if (job === undefined) {
33
+ return Option.none();
34
+ }
35
+ const data = yield* decodeStoredData(queue, job.data);
36
+ const output = job.output === null || deferralOf(job) !== undefined ? job.output : yield* decodeFailure(queue.name, job.output);
37
+ const sourceOutput = job.sourceOutput === null ? null : yield* decodeFailure(queue.name, job.sourceOutput);
38
+ return Option.some({ ...job, data: redact(data), output, sourceOutput });
39
+ }),
23
40
  ),
24
41
  );
25
42
  }
@@ -1,34 +1,22 @@
1
- import { type Context, Effect, type Scope } from "effect";
1
+ import { type Context, Effect, Scope } from "effect";
2
+ import type { StopOptions } from "pg-boss";
2
3
  import { registerWorker } from "#adapters/registerWorker.ts";
4
+ import { workerDiagnostics } from "#adapters/workerDiagnostics.ts";
3
5
  import type { PgBossClient } from "#client.ts";
4
- import type { JobRegistration, QueueDefinition, QueueWorker, ScheduleDefinition, ScheduledWorker } from "#definition.ts";
6
+ import type { JobPayloadSchema, JobRegistration, QueueDefinition, QueueWorker, ScheduleDefinition, ScheduledWorker } from "#definition.ts";
5
7
  import { PgBossQueueOptionsError, toPgBossError } from "#error.ts";
6
8
  import { ensureQueue } from "#internal/ensureQueue.ts";
7
9
  import { runQueueWorker } from "#internal/runQueueWorker.ts";
8
-
9
- function isQueueWorker(registration: JobRegistration): registration is QueueWorker {
10
- return registration._tag === "QueueWorker";
11
- }
12
-
13
- function isScheduledWorker(registration: JobRegistration): registration is ScheduledWorker {
14
- return registration._tag === "ScheduledWorker";
15
- }
16
-
17
- function isQueueDefinition(registration: JobRegistration): registration is QueueDefinition {
18
- return registration._tag === "QueueDefinition";
19
- }
10
+ import { registerTestingQueue } from "#internal/testingRegistrations.ts";
20
11
 
21
12
  function registrationDefinition(registration: JobRegistration): QueueDefinition | ScheduleDefinition {
22
- if (isQueueDefinition(registration)) {
13
+ if (registration._tag === "QueueDefinition") {
23
14
  return registration;
24
15
  }
25
- if (isQueueWorker(registration)) {
16
+ if (registration._tag === "QueueWorker") {
26
17
  return registration.queue;
27
18
  }
28
- if (isScheduledWorker(registration)) {
29
- return registration.schedule;
30
- }
31
- throw new TypeError(`Unknown pg-boss registration: ${registration._tag}`);
19
+ return registration.schedule;
32
20
  }
33
21
 
34
22
  export function registrationName(registration: JobRegistration): string {
@@ -37,7 +25,7 @@ export function registrationName(registration: JobRegistration): string {
37
25
 
38
26
  const prepareQueues = Effect.fnUntraced(function* (client: PgBossClient, registrations: readonly JobRegistration[], replaceWorkers: boolean) {
39
27
  for (const registration of registrations) {
40
- if (replaceWorkers && !isQueueDefinition(registration)) {
28
+ if (replaceWorkers && registration._tag !== "QueueDefinition") {
41
29
  yield* Effect.tryPromise({ catch: (error) => toPgBossError("register", error), try: () => client.offWork(registrationName(registration)) });
42
30
  }
43
31
  }
@@ -49,19 +37,13 @@ const prepareQueues = Effect.fnUntraced(function* (client: PgBossClient, registr
49
37
  }
50
38
  });
51
39
 
52
- // The layer requires RegistrationRequirements of its jobs, so its context provides every registered worker.
53
- function workerContext<R>(context: Context.Context<R>): Context.Context<unknown>;
54
- function workerContext(context: unknown): unknown {
55
- return context;
56
- }
57
-
58
- const registerQueue = Effect.fnUntraced(function* (
40
+ const registerQueue = Effect.fnUntraced(function* <R>(
59
41
  client: PgBossClient,
60
- registration: QueueWorker | QueueDefinition,
61
- context: Context.Context<unknown>,
42
+ registration: QueueWorker<JobPayloadSchema & { readonly DecodingServices: R }, R> | QueueDefinition,
43
+ context: Context.Context<R>,
62
44
  scope: Scope.Scope,
63
45
  schema: string,
64
- graceful: boolean,
46
+ stop: StopOptions | undefined,
65
47
  ) {
66
48
  const queue = registration._tag === "QueueWorker" ? registration.queue : registration;
67
49
  const name = queue.name;
@@ -76,22 +58,29 @@ const registerQueue = Effect.fnUntraced(function* (
76
58
  scope,
77
59
  schema,
78
60
  queue.maxDeferrals,
79
- graceful,
61
+ stop,
80
62
  ).pipe(Effect.provide(context));
81
63
  });
82
64
 
83
- const registerSchedule = Effect.fnUntraced(function* (
65
+ const registerSchedule = Effect.fnUntraced(function* <R>(
84
66
  client: PgBossClient,
85
- worker: ScheduledWorker,
86
- context: Context.Context<unknown>,
67
+ worker: ScheduledWorker<R>,
68
+ context: Context.Context<R>,
87
69
  scope: Scope.Scope,
88
70
  schema: string,
89
- graceful: boolean,
71
+ stop: StopOptions | undefined,
90
72
  ) {
91
73
  const { schedule } = worker;
92
- yield* registerWorker(client, schedule.name, schedule.workerOptions, () => worker.effect, scope, schema, schedule.maxDeferrals, graceful).pipe(
93
- Effect.provide(context),
94
- );
74
+ yield* registerWorker(
75
+ client,
76
+ schedule.name,
77
+ schedule.workerOptions,
78
+ () => workerDiagnostics(worker.effect),
79
+ scope,
80
+ schema,
81
+ schedule.maxDeferrals,
82
+ stop,
83
+ ).pipe(Effect.provide(context));
95
84
  yield* Effect.tryPromise({
96
85
  catch: (error) => toPgBossError("register", error, schedule.name),
97
86
  try: () => client.schedule(schedule.name, schedule.cron, null, schedule.scheduleOptions),
@@ -108,25 +97,27 @@ export function registrationNames(registrations: readonly JobRegistration[]): re
108
97
 
109
98
  export const registerJobs = Effect.fnUntraced(function* <R>(
110
99
  client: PgBossClient,
111
- registrations: readonly JobRegistration[],
100
+ registrations: readonly JobRegistration<R>[],
112
101
  context: Context.Context<R>,
113
102
  replaceWorkers: boolean,
114
103
  schema = "pgboss",
115
104
  startWorkers = true,
116
- graceful = true,
105
+ stop?: StopOptions,
117
106
  ) {
118
- const scope = yield* Effect.scope;
107
+ const scope = yield* Scope.fork(yield* Effect.scope, "parallel");
119
108
  yield* Effect.try({ catch: (error) => toPgBossError("register", error), try: () => registrationNames(registrations) });
120
109
  yield* prepareQueues(client, registrations, replaceWorkers);
110
+ for (const registration of registrations) {
111
+ registerTestingQueue(client, registrationName(registration));
112
+ }
121
113
  if (!startWorkers) {
122
114
  return;
123
115
  }
124
- const provided = workerContext(context);
125
116
  for (const registration of registrations) {
126
- if (isQueueWorker(registration) || isQueueDefinition(registration)) {
127
- yield* registerQueue(client, registration, provided, scope, schema, graceful);
128
- } else if (isScheduledWorker(registration)) {
129
- yield* registerSchedule(client, registration, provided, scope, schema, graceful);
117
+ if (registration._tag === "QueueWorker" || registration._tag === "QueueDefinition") {
118
+ yield* registerQueue(client, registration, context, scope, schema, stop);
119
+ } else {
120
+ yield* registerSchedule(client, registration, context, scope, schema, stop);
130
121
  }
131
122
  }
132
123
  });
@@ -1,4 +1,5 @@
1
1
  import { type Context, Effect, Option, Scope } from "effect";
2
+ import type { StopOptions } from "pg-boss";
2
3
  import { WorkerRelease } from "@shivaedev/sql/WorkerRelease.ts";
3
4
  import { retireJobs } from "#adapters/retireJobs.ts";
4
5
  import type { PgBossClient } from "#client.ts";
@@ -8,24 +9,24 @@ import { registerJobs, registrationName } from "./register.ts";
8
9
 
9
10
  export const registerReleasedJobs = Effect.fnUntraced(function* <R>(
10
11
  client: PgBossClient,
11
- jobs: readonly JobRegistration[],
12
+ jobs: readonly JobRegistration<R>[],
12
13
  context: Context.Context<R>,
13
14
  replaceWorkers: boolean,
14
15
  schema: string | undefined,
15
- graceful: boolean,
16
+ stop: StopOptions | undefined,
16
17
  retired: readonly string[] = [],
17
18
  ) {
18
19
  const signal = yield* Effect.serviceOption(WorkerRelease);
19
20
  const workers = jobs.filter((job) => job._tag !== "QueueDefinition");
20
21
  const scope = yield* Effect.scope;
21
22
  const register = retireJobs(client, jobs, retired).pipe(
22
- Effect.andThen(registerJobs(client, jobs, context, replaceWorkers, schema, true, graceful)),
23
+ Effect.andThen(registerJobs(client, jobs, context, replaceWorkers, schema, true, stop)),
23
24
  Effect.provideService(Scope.Scope, scope),
24
25
  );
25
26
  if (Option.isNone(signal) || (workers.length === 0 && retired.length === 0)) {
26
27
  return yield* register;
27
28
  }
28
- yield* registerJobs(client, jobs, context, replaceWorkers, schema, false, graceful);
29
+ yield* registerJobs(client, jobs, context, replaceWorkers, schema, false, stop);
29
30
  let working = replaceWorkers;
30
31
  let paused = false;
31
32
  yield* signal.value.register((active) =>
@@ -1,9 +1,10 @@
1
1
  import { Clock, Effect } from "effect";
2
2
  import { redactDiagnosticCause } from "@shivaedev/errors/redactCause.ts";
3
+ import { batchClockOffset } from "#adapters/batchClockOffset.ts";
3
4
  import type { PgBossClient } from "#client.ts";
4
5
  import { toPgBossError } from "#error.ts";
5
6
 
6
- export function retryWake(client: PgBossClient, name: string, id: string, registration: Promise<string>): Effect.Effect<void> {
7
+ export function retryWake(client: PgBossClient, schema: string, name: string, id: string, registration: Promise<string>): Effect.Effect<void> {
7
8
  return Effect.gen(function* () {
8
9
  const findJobs = client.findJobs?.bind(client);
9
10
  if (findJobs === undefined || client.notifyWorker === undefined) {
@@ -15,8 +16,9 @@ export function retryWake(client: PgBossClient, name: string, id: string, regist
15
16
  return;
16
17
  }
17
18
  const workId = yield* Effect.tryPromise({ catch: (error) => toPgBossError("register", error, name), try: () => registration });
19
+ const offset = yield* batchClockOffset(client, schema, name);
18
20
  // Host timers can fire early; PostgreSQL retains precision that Date drops.
19
- const deadline = job.startAfter.getTime() + 1;
21
+ const deadline = job.startAfter.getTime() + offset + 1;
20
22
  let remaining = deadline - (yield* Clock.currentTimeMillis);
21
23
  while (remaining > 0) {
22
24
  yield* Effect.sleep(remaining);
@@ -3,11 +3,17 @@ import type { Job } from "pg-boss";
3
3
  import { deferralCount } from "#adapters/deferralCount.ts";
4
4
  import { workerDiagnostics } from "#adapters/workerDiagnostics.ts";
5
5
  import type { PgBossClient } from "#client.ts";
6
- import { jobContext, type QueueWorker } from "#definition.ts";
6
+ import { type JobPayloadSchema, jobContext, type QueueWorker } from "#definition.ts";
7
7
  import { PgBossPayloadError } from "#error.ts";
8
8
  import { payloadOf } from "#internal/tracing.ts";
9
9
 
10
- export function runQueueWorker(worker: QueueWorker, job: Job<unknown>, deadline: number, client: PgBossClient, schema: string) {
10
+ export function runQueueWorker<R>(
11
+ worker: QueueWorker<JobPayloadSchema & { readonly DecodingServices: R }, R>,
12
+ job: Job<unknown>,
13
+ deadline: number,
14
+ client: PgBossClient,
15
+ schema: string,
16
+ ) {
11
17
  return Schema.decodeUnknownEffect(Schema.toCodecJson(worker.queue.schema))(payloadOf(job.data)).pipe(
12
18
  Effect.mapError(
13
19
  (error) =>
@@ -5,6 +5,7 @@ import { deferralObservers } from "#internal/deferral.ts";
5
5
  import { expireAttempt } from "#internal/expireAttempt.ts";
6
6
  import { observeTestingSettlements } from "#internal/observeTestingSettlements.ts";
7
7
  import type { TestingBatch, TestingWork } from "#internal/testingIdle.ts";
8
+ import { testingRegistration } from "#internal/testingRegistrations.ts";
8
9
 
9
10
  export interface TestingControls {
10
11
  readonly changed: () => void;
@@ -94,7 +95,6 @@ export function testingClient(client: PgBoss, schema: string, controls: TestingC
94
95
  }),
95
96
  work: (name, options, handler) => {
96
97
  settlements.watch(name);
97
- controls.work.queues.add(name);
98
98
  return client.work(name, options, async (jobs: Job<unknown>[]) => {
99
99
  const batch: TestingBatch = { name, settled: false };
100
100
  controls.work.batches.add(batch);
@@ -121,6 +121,9 @@ export function testingClient(client: PgBoss, schema: string, controls: TestingC
121
121
  };
122
122
  const wrapped = new Proxy(client, {
123
123
  get(target, property) {
124
+ if (property === testingRegistration) {
125
+ return (name: string) => controls.work.queues.add(name);
126
+ }
124
127
  if (Object.hasOwn(overrides, property)) {
125
128
  const override: unknown = Reflect.get(overrides, property);
126
129
  return override;
@@ -1,4 +1,4 @@
1
- import { Effect, Schema } from "effect";
1
+ import { Clock, Effect, Schema } from "effect";
2
2
  import type { PgBoss } from "pg-boss";
3
3
  import type { PgBossTestingError } from "#error.ts";
4
4
  import { quotedSchema } from "#internal/storedRows.ts";
@@ -46,7 +46,12 @@ export function testingIdle(
46
46
  if (work.queues.size === 0 || !(unsettled || (yield* busy))) {
47
47
  return;
48
48
  }
49
- yield* signal.waitForChange(revision);
49
+ if (unsettled) {
50
+ yield* signal.waitForChange(revision);
51
+ } else {
52
+ // Other clients have no local callback; PostgreSQL remains the source of idle state.
53
+ yield* Effect.raceFirst(signal.waitForChange(revision), Effect.sleep("50 millis"));
54
+ }
50
55
  }
51
- });
56
+ }).pipe(Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()));
52
57
  }
@@ -0,0 +1,10 @@
1
+ import type { PgBossClient } from "#client.ts";
2
+
3
+ export const testingRegistration = Symbol("jobs testing registration");
4
+
5
+ export function registerTestingQueue(client: PgBossClient, name: string) {
6
+ const observe: unknown = Reflect.get(client, testingRegistration);
7
+ if (typeof observe === "function") {
8
+ observe(name);
9
+ }
10
+ }
@@ -11,7 +11,7 @@ const Rows = Schema.Array(
11
11
  id: Schema.String,
12
12
  queue: Schema.String,
13
13
  startAfter: Schema.DateTimeUtcFromDate,
14
- state: Schema.Literals(["created", "retry", "active", "failed"]),
14
+ state: Schema.Literals(["created", "retry", "deferred", "active", "failed"]),
15
15
  }),
16
16
  );
17
17
 
@@ -29,7 +29,10 @@ export function testingSnapshot(client: () => PgBoss, schema: () => string) {
29
29
  const { rows } = await client()
30
30
  .getDb()
31
31
  .executeSql(
32
- `SELECT id, name AS queue, state, data, start_after AS "startAfter"
32
+ `SELECT id, name AS queue, data, start_after AS "startAfter",
33
+ CASE WHEN state = 'retry' AND started_on IS NULL AND start_after > ${quoted}.job_now()
34
+ AND output ? 'jobDeferral'
35
+ THEN 'deferred' ELSE state::text END AS state
33
36
  FROM ${quoted}.job
34
37
  WHERE name = ANY($1::text[]) AND state IN ('created', 'retry', 'active', 'failed')
35
38
  ORDER BY created_on, id`,
@@ -0,0 +1,18 @@
1
+ import type { JobRegistration } from "#definition.ts";
2
+ import type { PgBossDefinition, PgBossLayerOptions } from "#service.ts";
3
+
4
+ export function jobModule<TIdentifier extends string, const TJobs extends readonly JobRegistration[], R = never>(
5
+ layer: PgBossDefinition<TIdentifier>["layer"],
6
+ options: PgBossLayerOptions<TJobs, R>,
7
+ ) {
8
+ const queues = options.jobs.map((job) => {
9
+ if (job._tag === "QueueDefinition") {
10
+ return job;
11
+ }
12
+ return job._tag === "QueueWorker" ? job.queue : job.schedule;
13
+ });
14
+ return {
15
+ queues,
16
+ workers: layer(options),
17
+ };
18
+ }
@@ -2,6 +2,7 @@ import { Context, Effect, Exit, Layer, type Option, Redacted, type Schema, Scope
2
2
  import type { ConstructorOptions, JobWithMetadata, SendOptions, StopOptions } from "pg-boss";
3
3
  import { defineService } from "@shivaedev/service/defineService.ts";
4
4
  import { genericMethod } from "@shivaedev/service/genericMethod.ts";
5
+ import { clientErrorLogger } from "#adapters/clientErrorLogger.ts";
5
6
  import { clientErrorReporter } from "#adapters/clientErrorReporter.ts";
6
7
  import type { PgBossClientFactory } from "#client.ts";
7
8
  import type { JobPayloadSchema, JobRegistration, QueueDefinition, RegistrationRequirements } from "#definition.ts";
@@ -58,7 +59,7 @@ export function pgBossLayer(options: PgBossOptions): Layer.Layer<PgBoss, PgBossE
58
59
  return Layer.effect(
59
60
  PgBoss,
60
61
  Effect.gen(function* () {
61
- const report = yield* clientErrorReporter(logClientError);
62
+ const report = yield* clientErrorLogger();
62
63
  const acquired = yield* Effect.acquireRelease(
63
64
  acquireClient(
64
65
  {
@@ -92,7 +93,10 @@ export interface PgBossDefinition<TIdentifier extends string = string> extends C
92
93
  }
93
94
 
94
95
  export function makePgBoss<const TIdentifier extends string>(identifier: TIdentifier, queueNames?: readonly string[]): PgBossDefinition<TIdentifier> {
95
- function definition<R>(options: PgBossLayerOptions<readonly JobRegistration[], R>, dependencies: Context.Context<R>) {
96
+ function definition<R, E>(
97
+ options: PgBossLayerOptions<readonly JobRegistration<R>[], E>,
98
+ dependencies: Context.Context<Exclude<R, TIdentifier> | E>,
99
+ ) {
96
100
  return defineService({
97
101
  id: identifier,
98
102
  initialize: Effect.gen(function* () {
@@ -100,20 +104,16 @@ export function makePgBoss<const TIdentifier extends string>(identifier: TIdenti
100
104
  const acquired = yield* PgBoss;
101
105
  const workerScope = yield* Scope.make();
102
106
  const service = makeService(acquired.client, options.jobs.map(registrationName), acquired.schema);
103
- const context = Context.add(dependencies, Service, service);
104
- const reportError = options.onError ?? logClientError;
105
- const report = yield* clientErrorReporter(reportError).pipe(Effect.provide(context));
106
- const unsubscribe = subscribeClientErrors(acquired.client, report);
107
- yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void).pipe(Effect.ensuring(Effect.sync(unsubscribe))));
108
- yield* registerReleasedJobs(
109
- acquired.client,
110
- options.jobs,
111
- context,
112
- acquired.reused,
113
- acquired.schema,
114
- acquired.stop?.graceful !== false,
115
- options.retired,
116
- ).pipe(Effect.provideService(Scope.Scope, workerScope), Effect.provide(dependencies));
107
+ yield* Effect.gen(function* () {
108
+ const context = yield* Effect.context<R | E>();
109
+ const reportError = options.onError ?? logClientError;
110
+ const report = yield* clientErrorReporter(reportError).pipe(Effect.provide(context));
111
+ const unsubscribe = subscribeClientErrors(acquired.client, report);
112
+ yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void).pipe(Effect.ensuring(Effect.sync(unsubscribe))));
113
+ yield* registerReleasedJobs(acquired.client, options.jobs, context, acquired.reused, acquired.schema, acquired.stop, options.retired).pipe(
114
+ Effect.provideService(Scope.Scope, workerScope),
115
+ );
116
+ }).pipe(Effect.provideService(Service, service), Effect.provide(dependencies));
117
117
  return service;
118
118
  }),
119
119
  methods: (service) => ({
@@ -128,12 +128,16 @@ export function makePgBoss<const TIdentifier extends string>(identifier: TIdenti
128
128
  requires: [PgBoss],
129
129
  });
130
130
  }
131
- const Service: Context.Service<TIdentifier, PgBossService> = definition({ jobs: [] }, Context.empty());
132
- function layer<const TRegistrations extends readonly JobRegistration[], TErrorRequirements = never>(
133
- options: PgBossLayerOptions<TRegistrations, TErrorRequirements>,
134
- ) {
135
- type Requirements = Exclude<RegistrationRequirements<TRegistrations>, TIdentifier> | TErrorRequirements;
136
- return Layer.unwrap(Effect.map(Effect.context<Requirements>(), (dependencies) => definition(options, dependencies).layer));
131
+ const Service: Context.Service<TIdentifier, PgBossService> = definition<never, never>({ jobs: [] }, Context.empty());
132
+ function layer<const TRegistrations extends readonly JobRegistration[], E = never>(
133
+ options: PgBossLayerOptions<TRegistrations, E>,
134
+ ): Layer.Layer<
135
+ TIdentifier,
136
+ PgBossError | PgBossQueueNamesError | PgBossQueueOptionsError,
137
+ PgBoss | Exclude<RegistrationRequirements<TRegistrations>, TIdentifier> | E
138
+ >;
139
+ function layer<R = never, E = never>(options: PgBossLayerOptions<readonly JobRegistration<R>[], E>) {
140
+ return Layer.unwrap(Effect.map(Effect.context<Exclude<R, TIdentifier> | E>(), (dependencies) => definition(options, dependencies).layer));
137
141
  }
138
142
  return Object.assign(Service, { layer });
139
143
  }
@@ -1,4 +1,4 @@
1
- import { type DateTime, Duration, Effect, Redacted } from "effect";
1
+ import { type DateTime, Duration, Effect, Redacted, type Tracer } from "effect";
2
2
  import type { PgBoss } from "pg-boss";
3
3
  import { testingDueNow } from "#adapters/testingDueNow.ts";
4
4
  import type { PgBossClientFactory } from "#client.ts";
@@ -22,7 +22,7 @@ export interface UnfinishedJob<TQueue extends SnapshotQueue> {
22
22
  readonly id: string;
23
23
  readonly queue: TQueue["name"];
24
24
  readonly startAfter: DateTime.Utc;
25
- readonly state: "active" | "created" | "failed" | "retry";
25
+ readonly state: "active" | "created" | "deferred" | "failed" | "retry";
26
26
  }
27
27
 
28
28
  export interface PgBossTestingWaitOptions {
@@ -35,6 +35,17 @@ export interface PgBossTestingOptions {
35
35
  readonly retryPolicy?: typeof testRetryPolicy;
36
36
  }
37
37
 
38
+ export function linkedWorkerSpans(sender: Pick<Tracer.AnySpan, "spanId" | "traceId">, spans: readonly Tracer.Span[]) {
39
+ return Effect.sync(() =>
40
+ spans.filter(
41
+ (span) =>
42
+ span.kind === "consumer"
43
+ && span.attributes.get("messaging.system") === "pg-boss"
44
+ && span.links.some((link) => link.span.spanId === sender.spanId && link.span.traceId === sender.traceId),
45
+ ),
46
+ );
47
+ }
48
+
38
49
  export function makePgBossTesting(options: PgBossTestingOptions = {}) {
39
50
  let current: PgBoss | undefined;
40
51
  let fetches: TestingFetches | undefined;