@shivaedev/jobs 0.0.0 → 0.19.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/CHANGELOG.md +319 -0
- package/LICENSE +21 -0
- package/README.md +527 -1
- package/dist/server/client.d.ts +25 -0
- package/dist/server/client.d.ts.map +1 -0
- package/dist/server/client.js +3 -0
- package/dist/server/client.js.map +1 -0
- package/dist/server/definition.d.ts +96 -0
- package/dist/server/definition.d.ts.map +1 -0
- package/dist/server/definition.js +40 -0
- package/dist/server/definition.js.map +1 -0
- package/dist/server/error.d.ts +51 -0
- package/dist/server/error.d.ts.map +1 -0
- package/dist/server/error.js +37 -0
- package/dist/server/error.js.map +1 -0
- package/dist/server/health.d.ts +51 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +4 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/internal/batchHandler.d.ts +5 -0
- package/dist/server/internal/batchHandler.d.ts.map +1 -0
- package/dist/server/internal/batchHandler.js +51 -0
- package/dist/server/internal/batchHandler.js.map +1 -0
- package/dist/server/internal/clientCache.d.ts +8 -0
- package/dist/server/internal/clientCache.d.ts.map +1 -0
- package/dist/server/internal/clientCache.js +61 -0
- package/dist/server/internal/clientCache.js.map +1 -0
- package/dist/server/internal/clientErrors.d.ts +8 -0
- package/dist/server/internal/clientErrors.d.ts.map +1 -0
- package/dist/server/internal/clientErrors.js +49 -0
- package/dist/server/internal/clientErrors.js.map +1 -0
- package/dist/server/internal/countsFor.d.ts +5 -0
- package/dist/server/internal/countsFor.d.ts.map +1 -0
- package/dist/server/internal/countsFor.js +28 -0
- package/dist/server/internal/countsFor.js.map +1 -0
- package/dist/server/internal/deadlineOf.d.ts +3 -0
- package/dist/server/internal/deadlineOf.d.ts.map +1 -0
- package/dist/server/internal/deadlineOf.js +7 -0
- package/dist/server/internal/deadlineOf.js.map +1 -0
- package/dist/server/internal/deferrableAttempt.d.ts +7 -0
- package/dist/server/internal/deferrableAttempt.d.ts.map +1 -0
- package/dist/server/internal/deferrableAttempt.js +28 -0
- package/dist/server/internal/deferrableAttempt.js.map +1 -0
- package/dist/server/internal/deferral.d.ts +15 -0
- package/dist/server/internal/deferral.d.ts.map +1 -0
- package/dist/server/internal/deferral.js +14 -0
- package/dist/server/internal/deferral.js.map +1 -0
- package/dist/server/internal/deferralStorePlan.d.ts +2 -0
- package/dist/server/internal/deferralStorePlan.d.ts.map +1 -0
- package/dist/server/internal/deferralStorePlan.js +35 -0
- package/dist/server/internal/deferralStorePlan.js.map +1 -0
- package/dist/server/internal/definitionOptions.d.ts +17 -0
- package/dist/server/internal/definitionOptions.d.ts.map +1 -0
- package/dist/server/internal/definitionOptions.js +12 -0
- package/dist/server/internal/definitionOptions.js.map +1 -0
- package/dist/server/internal/enqueueFor.d.ts +4 -0
- package/dist/server/internal/enqueueFor.d.ts.map +1 -0
- package/dist/server/internal/enqueueFor.js +23 -0
- package/dist/server/internal/enqueueFor.js.map +1 -0
- package/dist/server/internal/ensureQueue.d.ts +4 -0
- package/dist/server/internal/ensureQueue.d.ts.map +1 -0
- package/dist/server/internal/ensureQueue.js +44 -0
- package/dist/server/internal/ensureQueue.js.map +1 -0
- package/dist/server/internal/expireAttempt.d.ts +3 -0
- package/dist/server/internal/expireAttempt.d.ts.map +1 -0
- package/dist/server/internal/expireAttempt.js +17 -0
- package/dist/server/internal/expireAttempt.js.map +1 -0
- package/dist/server/internal/healthFor.d.ts +5 -0
- package/dist/server/internal/healthFor.d.ts.map +1 -0
- package/dist/server/internal/healthFor.js +27 -0
- package/dist/server/internal/healthFor.js.map +1 -0
- package/dist/server/internal/jobControl.d.ts +5 -0
- package/dist/server/internal/jobControl.d.ts.map +1 -0
- package/dist/server/internal/jobControl.js +35 -0
- package/dist/server/internal/jobControl.js.map +1 -0
- package/dist/server/internal/lifecycle.d.ts +17 -0
- package/dist/server/internal/lifecycle.d.ts.map +1 -0
- package/dist/server/internal/lifecycle.js +55 -0
- package/dist/server/internal/lifecycle.js.map +1 -0
- package/dist/server/internal/listJobs.d.ts +5 -0
- package/dist/server/internal/listJobs.d.ts.map +1 -0
- package/dist/server/internal/listJobs.js +69 -0
- package/dist/server/internal/listJobs.js.map +1 -0
- package/dist/server/internal/makeService.d.ts +4 -0
- package/dist/server/internal/makeService.d.ts.map +1 -0
- package/dist/server/internal/makeService.js +17 -0
- package/dist/server/internal/makeService.js.map +1 -0
- package/dist/server/internal/nativeClient.d.ts +3 -0
- package/dist/server/internal/nativeClient.d.ts.map +1 -0
- package/dist/server/internal/nativeClient.js +28 -0
- package/dist/server/internal/nativeClient.js.map +1 -0
- package/dist/server/internal/observeTestingSettlements.d.ts +6 -0
- package/dist/server/internal/observeTestingSettlements.d.ts.map +1 -0
- package/dist/server/internal/observeTestingSettlements.js +35 -0
- package/dist/server/internal/observeTestingSettlements.js.map +1 -0
- package/dist/server/internal/register.d.ts +8 -0
- package/dist/server/internal/register.d.ts.map +1 -0
- package/dist/server/internal/register.js +98 -0
- package/dist/server/internal/register.js.map +1 -0
- package/dist/server/internal/registerReleasedJobs.d.ts +5 -0
- package/dist/server/internal/registerReleasedJobs.d.ts.map +1 -0
- package/dist/server/internal/registerReleasedJobs.js +35 -0
- package/dist/server/internal/registerReleasedJobs.js.map +1 -0
- package/dist/server/internal/registerWorker.d.ts +5 -0
- package/dist/server/internal/registerWorker.d.ts.map +1 -0
- package/dist/server/internal/registerWorker.js +33 -0
- package/dist/server/internal/registerWorker.js.map +1 -0
- package/dist/server/internal/runSchemaPlan.d.ts +7 -0
- package/dist/server/internal/runSchemaPlan.d.ts.map +1 -0
- package/dist/server/internal/runSchemaPlan.js +25 -0
- package/dist/server/internal/runSchemaPlan.js.map +1 -0
- package/dist/server/internal/scheduleDefinition.d.ts +3 -0
- package/dist/server/internal/scheduleDefinition.d.ts.map +1 -0
- package/dist/server/internal/scheduleDefinition.js +19 -0
- package/dist/server/internal/scheduleDefinition.js.map +1 -0
- package/dist/server/internal/schemaIndexBuilds.d.ts +4 -0
- package/dist/server/internal/schemaIndexBuilds.d.ts.map +1 -0
- package/dist/server/internal/schemaIndexBuilds.js +34 -0
- package/dist/server/internal/schemaIndexBuilds.js.map +1 -0
- package/dist/server/internal/settleDeferral.d.ts +7 -0
- package/dist/server/internal/settleDeferral.d.ts.map +1 -0
- package/dist/server/internal/settleDeferral.js +48 -0
- package/dist/server/internal/settleDeferral.js.map +1 -0
- package/dist/server/internal/storedRows.d.ts +6 -0
- package/dist/server/internal/storedRows.d.ts.map +1 -0
- package/dist/server/internal/storedRows.js +10 -0
- package/dist/server/internal/storedRows.js.map +1 -0
- package/dist/server/internal/testingClient.d.ts +13 -0
- package/dist/server/internal/testingClient.d.ts.map +1 -0
- package/dist/server/internal/testingClient.js +117 -0
- package/dist/server/internal/testingClient.js.map +1 -0
- package/dist/server/internal/testingDeferrals.d.ts +11 -0
- package/dist/server/internal/testingDeferrals.d.ts.map +1 -0
- package/dist/server/internal/testingDeferrals.js +29 -0
- package/dist/server/internal/testingDeferrals.js.map +1 -0
- package/dist/server/internal/testingExpirations.d.ts +6 -0
- package/dist/server/internal/testingExpirations.d.ts.map +1 -0
- package/dist/server/internal/testingExpirations.js +27 -0
- package/dist/server/internal/testingExpirations.js.map +1 -0
- package/dist/server/internal/testingIdle.d.ts +16 -0
- package/dist/server/internal/testingIdle.d.ts.map +1 -0
- package/dist/server/internal/testingIdle.js +31 -0
- package/dist/server/internal/testingIdle.js.map +1 -0
- package/dist/server/internal/testingReadout.d.ts +44 -0
- package/dist/server/internal/testingReadout.d.ts.map +1 -0
- package/dist/server/internal/testingReadout.js +13 -0
- package/dist/server/internal/testingReadout.js.map +1 -0
- package/dist/server/internal/testingSignal.d.ts +7 -0
- package/dist/server/internal/testingSignal.d.ts.map +1 -0
- package/dist/server/internal/testingSignal.js +29 -0
- package/dist/server/internal/testingSignal.js.map +1 -0
- package/dist/server/internal/testingSnapshot.d.ts +6 -0
- package/dist/server/internal/testingSnapshot.d.ts.map +1 -0
- package/dist/server/internal/testingSnapshot.js +32 -0
- package/dist/server/internal/testingSnapshot.js.map +1 -0
- package/dist/server/internal/tracing.d.ts +6 -0
- package/dist/server/internal/tracing.d.ts.map +1 -0
- package/dist/server/internal/tracing.js +37 -0
- package/dist/server/internal/tracing.js.map +1 -0
- package/dist/server/internal/waitForBatchExpiry.d.ts +6 -0
- package/dist/server/internal/waitForBatchExpiry.d.ts.map +1 -0
- package/dist/server/internal/waitForBatchExpiry.js +28 -0
- package/dist/server/internal/waitForBatchExpiry.js.map +1 -0
- package/dist/server/schema.d.ts +10 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +67 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/service.d.ts +29 -0
- package/dist/server/service.d.ts.map +1 -0
- package/dist/server/service.js +33 -0
- package/dist/server/service.js.map +1 -0
- package/dist/server/testing.d.ts +77 -0
- package/dist/server/testing.d.ts.map +1 -0
- package/dist/server/testing.js +90 -0
- package/dist/server/testing.js.map +1 -0
- package/package.json +71 -4
- package/src/server/client.ts +40 -0
- package/src/server/definition.ts +140 -0
- package/src/server/error.ts +73 -0
- package/src/server/health.ts +60 -0
- package/src/server/internal/batchHandler.ts +71 -0
- package/src/server/internal/clientCache.ts +73 -0
- package/src/server/internal/clientErrors.ts +64 -0
- package/src/server/internal/countsFor.ts +34 -0
- package/src/server/internal/deadlineOf.ts +8 -0
- package/src/server/internal/deferrableAttempt.ts +41 -0
- package/src/server/internal/deferral.ts +19 -0
- package/src/server/internal/deferralStorePlan.ts +35 -0
- package/src/server/internal/definitionOptions.ts +14 -0
- package/src/server/internal/enqueueFor.ts +39 -0
- package/src/server/internal/ensureQueue.ts +47 -0
- package/src/server/internal/expireAttempt.ts +21 -0
- package/src/server/internal/healthFor.ts +35 -0
- package/src/server/internal/jobControl.ts +44 -0
- package/src/server/internal/lifecycle.ts +68 -0
- package/src/server/internal/listJobs.ts +98 -0
- package/src/server/internal/makeService.ts +19 -0
- package/src/server/internal/nativeClient.ts +30 -0
- package/src/server/internal/observeTestingSettlements.ts +36 -0
- package/src/server/internal/register.ts +145 -0
- package/src/server/internal/registerReleasedJobs.ts +44 -0
- package/src/server/internal/registerWorker.ts +56 -0
- package/src/server/internal/runSchemaPlan.ts +32 -0
- package/src/server/internal/scheduleDefinition.ts +21 -0
- package/src/server/internal/schemaIndexBuilds.ts +44 -0
- package/src/server/internal/settleDeferral.ts +54 -0
- package/src/server/internal/storedRows.ts +17 -0
- package/src/server/internal/testingClient.ts +134 -0
- package/src/server/internal/testingDeferrals.ts +44 -0
- package/src/server/internal/testingExpirations.ts +31 -0
- package/src/server/internal/testingIdle.ts +52 -0
- package/src/server/internal/testingReadout.ts +19 -0
- package/src/server/internal/testingSignal.ts +30 -0
- package/src/server/internal/testingSnapshot.ts +53 -0
- package/src/server/internal/tracing.ts +44 -0
- package/src/server/internal/waitForBatchExpiry.ts +30 -0
- package/src/server/schema.ts +84 -0
- package/src/server/service.ts +100 -0
- package/src/server/testing.ts +118 -0
package/README.md
CHANGED
|
@@ -1,3 +1,529 @@
|
|
|
1
1
|
# @shivaedev/jobs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run PostgreSQL jobs through the same Effect services as the rest of your application. A queue has one payload Schema for enqueueing and handling, and an Effect Layer owns the pg-boss client, workers and schedules.
|
|
4
|
+
|
|
5
|
+
## Why you want this
|
|
6
|
+
|
|
7
|
+
A background worker lives outside the request that queued it. Without a shared boundary, the producer writes one payload shape, the worker assumes another, and every worker rebuilds its dependencies and shutdown plumbing. This package puts those decisions in one place:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { defineQueue } from "@shivaedev/jobs/server/definition.ts";
|
|
11
|
+
import { Effect, Schema } from "effect";
|
|
12
|
+
import { Mail } from "./services.ts";
|
|
13
|
+
|
|
14
|
+
const SendEmail = defineQueue({
|
|
15
|
+
name: "send-email",
|
|
16
|
+
schema: Schema.Struct({ userId: Schema.NumberFromString }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const emailWorker = SendEmail.handle(({ userId }) =>
|
|
20
|
+
Effect.flatMap(Mail, (mail) => mail.send(userId)),
|
|
21
|
+
);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`Mail` is the application's Effect service, defined in the guide below. The worker receives a number even though the durable payload stores a string. The same Schema encodes the producer's payload and decodes the worker's input; malformed stored data fails before the handler runs. Provide the worker's services when the jobs Layer starts, and the handler uses that captured context when pg-boss calls it later.
|
|
25
|
+
|
|
26
|
+
## Using it
|
|
27
|
+
|
|
28
|
+
### How to think about it
|
|
29
|
+
|
|
30
|
+
A **queue definition** names a pg-boss queue and the Schema of its payload. Application code uses the Schema's decoded type; pg-boss receives its encoded type. Both must be objects. Defining a queue describes the contract; `queue.handle(handler)` pairs it with the Effect that performs the work.
|
|
31
|
+
|
|
32
|
+
A **schedule definition** describes a queue with a cron trigger. `schedule.run(effect)` pairs it with an Effect that needs no payload. Queue workers and scheduled workers are **registrations**: the values you put in the jobs Layer's `jobs` list.
|
|
33
|
+
|
|
34
|
+
The application creates one jobs service with `makePgBoss(identifier)`. Its Layer starts a client, registers the job list and captures the services those workers need. The Layer's scope owns its client reference and releases it when the scope ends; the final reference closes the client. Callers use the jobs service to enqueue a decoded payload, look up or retry a job, read queue health and live counts, or list failed and dead-lettered jobs.
|
|
35
|
+
|
|
36
|
+
Use a distinct literal identifier for each jobs service. The service and its Layer keep that identifier in their types, so providing a Layer for a different identifier leaves the required jobs service unprovided at compile time.
|
|
37
|
+
|
|
38
|
+
The work separates naturally: declare the application services once, define a queue and its worker for each feature, provide the complete registration list at the runtime boundary, and enqueue from ordinary application Effects.
|
|
39
|
+
|
|
40
|
+
### Once per database: install the schema
|
|
41
|
+
|
|
42
|
+
For an application on the app host of `@shivaedev/server`, register `pgBossSchema` beside the migration loader. The host's `migrate` mode installs or upgrades it after committing the application ledger. Its `check` and `wait` modes include pg-boss readiness in the same check as the numbered migrations.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { pgBossSchema } from "@shivaedev/jobs/server/schema.ts";
|
|
46
|
+
import { appDatabase } from "@shivaedev/server/appHost.ts";
|
|
47
|
+
|
|
48
|
+
const database = appDatabase({
|
|
49
|
+
migrations: {
|
|
50
|
+
loader,
|
|
51
|
+
components: [pgBossSchema({ schema: "app_jobs" })],
|
|
52
|
+
mode: "migrate",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The component uses `installPgBossSchema` as its only install and upgrade path. It is pending when the schema is missing or its stored version is below the installed pg-boss package's required schema version; the readiness error names the schema and both versions (`missing` for an absent version). Start every producer and worker with the same schema and `migrate: false`. Use host mode `"wait"` for services that must wait for a deployment's migration process; no additional jobs-layer wait is needed.
|
|
58
|
+
|
|
59
|
+
With server's `appDatabase({ migrations: { mode: "wait", release, ... } })`, each jobs Layer automatically reads sql's optional `WorkerRelease` Context service. When the database belongs to another release, it calls pg-boss `offWork` for every registered worker queue, lets active jobs finish, logs `Workers paused for another database release` once, and idles. Producers and enqueueing continue. When its release owns the marker again and old workers have drained, it registers workers through the normal path and logs `Workers resumed for database release`. Equality permits rollbacks; no version ordering or payload compatibility policy is added. With no service, registration behaves as before.
|
|
60
|
+
|
|
61
|
+
A worker keeps its sql heartbeat row live while draining; the owning release waits for all other live releases to step aside. A crashed worker stops blocking once its heartbeat is older than five polling intervals. Set an explicit queue `expireInSeconds` and a `stop: { graceful: true, timeout }` long enough to cover it. Scope shutdown on SIGTERM waits for the native pg-boss stop before releasing application dependencies. Give the host's termination grace a margin beyond that timeout; the starter uses 60-second expiry, 65-second stop and 75-second Railway draining.
|
|
62
|
+
|
|
63
|
+
`installPgBossSchema` installs or upgrades pg-boss through the provided Effect SQL client without starting workers or schedules. Run it outside an application transaction, in a release step or the `fixtures` Effect of `makePostgresTestLayer`, so stories inherit the schema from the migrated template:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { installPgBossSchema } from "@shivaedev/jobs/server/schema.ts";
|
|
67
|
+
|
|
68
|
+
const jobsFixture = installPgBossSchema({ schema: "app_jobs" });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The Effect requires `SqlClient` and fails with `PgBossError` whose `operation` is `"install"`. The schema defaults to `"pgboss"`. Repeating installation preserves existing jobs. Upgrades apply pg-boss's migration SQL and concurrent index builds, including partition indexes, before returning. Pending index commands are recorded in pg-boss's BAM table in the same transaction as the schema version. A retry resumes unfinished commands and repairs invalid indexes left by failed builds, even when the schema version has already committed.
|
|
72
|
+
|
|
73
|
+
After installation, pass the same schema and `migrate: false` to `Jobs.layer` to start producers and workers without schema installation or migration. Startup fails if the schema is missing. Ordinary Layer startup retains pg-boss's default migration behavior when `migrate` is omitted.
|
|
74
|
+
|
|
75
|
+
### Once per application: the jobs service
|
|
76
|
+
|
|
77
|
+
Define the application's jobs service and worker dependencies in `services.ts`:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { makePgBoss } from "@shivaedev/jobs/server/service.ts";
|
|
81
|
+
import { Context, Effect, Layer } from "effect";
|
|
82
|
+
|
|
83
|
+
export const Jobs = makePgBoss("@app/Jobs");
|
|
84
|
+
|
|
85
|
+
export class Mail extends Context.Service<Mail, {
|
|
86
|
+
readonly send: (userId: number) => Effect.Effect<void>;
|
|
87
|
+
readonly removeExpired: Effect.Effect<void>;
|
|
88
|
+
}>()("@app/Mail") {}
|
|
89
|
+
|
|
90
|
+
export const MailLive = Layer.succeed(Mail, {
|
|
91
|
+
send: (userId) => Effect.log({ event: "send_email", userId }),
|
|
92
|
+
removeExpired: Effect.log({ event: "remove_expired_mail" }),
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`Jobs` is the service that callers yield. `Mail` is a separate domain service that workers use. The example implementation logs calls so the wiring is complete; an application supplies its own implementation of those methods. The jobs package does not need a provider-specific interface.
|
|
97
|
+
|
|
98
|
+
### Once per feature: the payload and handler
|
|
99
|
+
|
|
100
|
+
Define the feature's registrations in `jobs.ts`:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { defineQueue, defineSchedule } from "@shivaedev/jobs/server/definition.ts";
|
|
104
|
+
import { Effect, Schema } from "effect";
|
|
105
|
+
import { Mail } from "./services.ts";
|
|
106
|
+
|
|
107
|
+
export const SendEmail = defineQueue({
|
|
108
|
+
name: "send-email",
|
|
109
|
+
schema: Schema.Struct({ userId: Schema.NumberFromString }),
|
|
110
|
+
queue: { retryLimit: 5 },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export const emailWorker = SendEmail.handle(({ userId }, job) =>
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const mail = yield* Mail;
|
|
116
|
+
yield* mail.send(userId);
|
|
117
|
+
yield* Effect.log({ jobId: job.id });
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
export const Cleanup = defineSchedule({
|
|
122
|
+
name: "cleanup-mail",
|
|
123
|
+
cron: "17 3 * * *",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const cleanupWorker = Cleanup.run(
|
|
127
|
+
Effect.flatMap(Mail, (mail) => mail.removeExpired),
|
|
128
|
+
);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The queue takes `{ userId: number }` in application code and stores `{ userId: string }`. Its handler receives the decoded number and a job context, including the job's `id`. Keep the queue definition available to producers; the runtime registers the worker created by `handle`.
|
|
132
|
+
|
|
133
|
+
The schedule runs a payload-free Effect. This schedule registers its cron expression with `tz: "UTC"`. The package creates a `<name>-dlq` dead-letter queue for both kinds of worker and supplies `retryLimit: 3` and `retryBackoff: true` by default. The email queue above overrides the retry limit to five. These are pg-boss registration settings; pg-boss runs the retry and scheduling engine.
|
|
134
|
+
|
|
135
|
+
The Layer requires the workers' Effect services. Leaving `Mail` unprovided leaves that requirement in the resulting Effect's type. A primitive Schema such as `Schema.String` is rejected as a queue definition because the durable contract must be an object.
|
|
136
|
+
|
|
137
|
+
### Backlog throughput and concurrency
|
|
138
|
+
|
|
139
|
+
Queue and schedule workers default to `batchSize: 10` and `burstWhenBatchFull: true`. pg-boss fetches again immediately after a full batch completes, until a fetch returns fewer than ten jobs. The adapter runs handlers sequentially within each batch, and pg-boss defaults `localConcurrency` to one. Set native `localConcurrency` to run multiple batches at once:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const Compare = defineQueue({
|
|
143
|
+
name: "compare",
|
|
144
|
+
schema: Schema.Struct({ id: Schema.Number }),
|
|
145
|
+
queue: { notify: true },
|
|
146
|
+
worker: { batchSize: 2, localConcurrency: 5 },
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
This worker runs up to five handlers at once. `worker` accepts pg-boss 12's `WorkOptions`, including `batchSize`, `localConcurrency`, `pollingIntervalSeconds`, `notifyPollingIntervalSeconds` and the burst options. Explicit options override the adapter defaults. pg-boss ignores `burstWhenBatchFull` at `batchSize: 1`; keep a batch size of at least two for immediate backlog draining. Each job is settled separately through pg-boss: success completes that job, and a typed failure, payload decoding failure or defect fails only that job with its own error. A failed job does not spend the retries of other jobs in its batch or rerun their successful handlers. Interruption stops the batch and leaves the interrupted and remaining jobs to pg-boss expiry; handlers must still tolerate repeated side effects across retries and interrupted attempts.
|
|
151
|
+
|
|
152
|
+
Apps do not need to shorten the polling backstop to drain a backlog. With `notify: true` and an active LISTEN/NOTIFY listener, pg-boss wakes workers on notifications and defaults to a 30-second backstop (`notifyPollingIntervalSeconds`). Without active notifications its base poll defaults to two seconds (`pollingIntervalSeconds`). Bursting takes precedence over both intervals. After an adapter worker fails a job, it reads pg-boss's stored retry time and schedules a wake for every native worker in its registration at that time, so an idle sibling can retry while another worker is busy. The wake belongs to the worker Layer's scope and is canceled when that scope closes. Override the backstop when the application needs a different bound for other work that becomes runnable without a notification, such as delayed jobs or retries initiated outside the adapter worker.
|
|
153
|
+
|
|
154
|
+
### Defer until a provider is ready
|
|
155
|
+
|
|
156
|
+
Fail a queue handler or scheduled Effect with `RetryAt` when a provider gives a time to try again:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { DateTime, Effect } from "effect";
|
|
160
|
+
import { RetryAt } from "@shivaedev/jobs/server/definition.ts";
|
|
161
|
+
|
|
162
|
+
const waitForQuota = Effect.fail(new RetryAt({
|
|
163
|
+
at: DateTime.makeUnsafe("2030-01-01T00:01:00Z"),
|
|
164
|
+
reason: "Provider quota resets",
|
|
165
|
+
}));
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The same job returns to waiting with `startAfter` set to `at`; its ID, singleton key, payload and retry count stay unchanged. A past or current `at` is eligible on the next fetch. Deferral does not dead-letter the job or call `onError`. Its job span ends OK with a `job.deferred` event whose `at` is an ISO UTC string and whose `reason` is the supplied string. Deferred jobs rely on polling, including queues using `notify: true`. Stately jobs sharing a singleton key can defer together and still run one at a time. While waiting, their native job metadata reports the adapter's `effect-stately-deferred` policy; a database trigger restores `stately` when pg-boss claims them, preserving its active-job exclusion.
|
|
169
|
+
|
|
170
|
+
Set `queue: { maxDeferrals: 20 }` on a queue or schedule to change the default limit of ten deferrals per job. The limit must be a nonnegative safe integer; zero disables deferral. Once the limit is reached, `RetryAt` follows the normal failure and retry path. The adapter stores the count in its own table in the pg-boss schema, preserving it across worker restarts, ordinary failures, expiry and heartbeat timeouts. Automatic migrations and `installPgBossSchema` create this table and the stately claim trigger; use `installPgBossSchema` before starting workers with `migrate: false`.
|
|
171
|
+
|
|
172
|
+
### One active job per entity
|
|
173
|
+
|
|
174
|
+
Put related job kinds in one tagged-union queue and choose the entity key in the definition:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const PullRequestJobs = defineQueue({
|
|
178
|
+
name: "pull-request",
|
|
179
|
+
schema: Schema.Union([
|
|
180
|
+
Schema.TaggedStruct("Refresh", { pullRequestId: Schema.NumberFromString }),
|
|
181
|
+
Schema.TaggedStruct("Reconcile", { pullRequestId: Schema.NumberFromString }),
|
|
182
|
+
]),
|
|
183
|
+
queue: { policy: "singleton" },
|
|
184
|
+
key: ({ pullRequestId }) => `pr:${pullRequestId.toFixed(0)}`,
|
|
185
|
+
worker: { batchSize: 1, localConcurrency: 3 },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const refresh = Effect.flatMap(Jobs, (jobs) =>
|
|
189
|
+
jobs.enqueue(PullRequestJobs, { _tag: "Refresh", pullRequestId: 42 }),
|
|
190
|
+
);
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`queue.policy` accepts pg-boss's policy literals and rejects typos. The `singleton` policy requires `key: (payload) => string`. The callback sees the decoded payload, so `pullRequestId` is a number. `enqueue` supplies pg-boss's `singletonKey` automatically; callers cannot supply a conflicting key in send options.
|
|
194
|
+
|
|
195
|
+
PostgreSQL guarantees one active job per key across workers, including different job kinds in this queue. Different keys can run in parallel when workers have capacity. A job waiting to retry frees its key for another job; exhausting retries sends it to the dead-letter queue and leaves the key available.
|
|
196
|
+
|
|
197
|
+
Singleton guarantees one active job per key, not order. Ordering stays the application's job. Keep handlers idempotent and write state only when the source is newer than what is stored, so an older event or retry cannot overwrite newer state.
|
|
198
|
+
|
|
199
|
+
### Size batches against expiry
|
|
200
|
+
|
|
201
|
+
pg-boss marks every job in a fetched batch active together. Each job's expiry window starts at its stored `startedOn`, including time waiting for earlier handlers. Size each queue's batch so its sequential work fits within that window; use a small batch for long-running work and keep the default for short work:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
const Ingest = defineQueue({
|
|
205
|
+
name: "ingest",
|
|
206
|
+
schema: Schema.Struct({ sourceId: Schema.String }),
|
|
207
|
+
queue: { expireInSeconds: 300 },
|
|
208
|
+
worker: { batchSize: 1 },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const IndexTitle = defineQueue({
|
|
212
|
+
name: "index-title",
|
|
213
|
+
schema: Schema.Struct({ titleId: Schema.String }),
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`Ingest` fetches one job at a time; `IndexTitle` keeps the default batch of ten. Configure the expiry for the work's expected duration. Increasing `localConcurrency` adds batches; it does not extend any job's window.
|
|
218
|
+
|
|
219
|
+
The adapter enforces each queue or scheduled job's deadline using pg-boss's `startedOn` and `expireInSeconds`. It skips jobs whose window has already passed and interrupts a running Effect at its deadline. Expired jobs remain for pg-boss's expiry and retry engine. Interruption stops the rest of that batch, as described above.
|
|
220
|
+
|
|
221
|
+
An interrupted batch waits for pg-boss to release its original claims before returning successfully. Settlement reads use exponential backoff from 25 ms to 500 ms between reads, with a bound of one second after the latest fetched job's deadline, including a read that never resolves. If settlement cannot be observed within that bound, the callback fails with `PgBossError` (`operation: "settle"`, with a redacted `TimeoutError`) and logs the error through the worker's captured Effect context.
|
|
222
|
+
|
|
223
|
+
Queue handlers can yield `job.remainingBudget`, an `Effect.Effect<Duration.Duration>` that recomputes the time left at each read and clamps it to zero. Use it to size a downstream operation's timeout:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { Duration, Effect } from "effect";
|
|
227
|
+
|
|
228
|
+
const ingestWorker = Ingest.handle(({ sourceId }, job) =>
|
|
229
|
+
Effect.gen(function* () {
|
|
230
|
+
const budget = yield* job.remainingBudget;
|
|
231
|
+
yield* Effect.log({ sourceId, remainingMilliseconds: Duration.toMillis(budget) });
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Keep handler work interruptible and use cancellation-aware Effect integrations for external operations. A deadline interrupts the Effect fiber; it cannot stop an uninterruptible region or an external side effect that ignores cancellation. Handlers must tolerate retries.
|
|
237
|
+
|
|
238
|
+
### Once per runtime: provide the workers
|
|
239
|
+
|
|
240
|
+
Build the jobs Layer in `runtime.ts`:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
import { Layer } from "effect";
|
|
244
|
+
import { cleanupWorker, emailWorker } from "./jobs.ts";
|
|
245
|
+
import { Jobs, MailLive } from "./services.ts";
|
|
246
|
+
|
|
247
|
+
export const jobsLayer = (databaseUrl: string) =>
|
|
248
|
+
Jobs.layer({
|
|
249
|
+
connectionString: databaseUrl,
|
|
250
|
+
schema: "app_jobs",
|
|
251
|
+
jobs: [emailWorker, cleanupWorker],
|
|
252
|
+
}).pipe(Layer.provide(MailLive));
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The constructor options belong to pg-boss. `jobs` is the complete list of registrations for this Layer. The Layer captures `MailLive` for both the queue handler and the scheduled Effect. A startup or registration failure fails Layer acquisition with `PgBossError` or `PgBossQueueOptionsError` and releases its acquired client reference. Registration creates a queue again, up to six times with a short backoff, when PostgreSQL rolls the creation back as a deadlock victim (SQLSTATE `40P01`), which a partitioned queue can meet when other queues in the schema have traffic. A shared client stays running while another scope still owns a reference.
|
|
256
|
+
|
|
257
|
+
Keep the Layer in the application's worker runtime scope. A short scope is useful for an acquisition or health check; a worker process needs its scope to remain open while it handles jobs.
|
|
258
|
+
|
|
259
|
+
Set `clientCacheKey` to share one reference-counted client across Layer builds with equal pg-boss constructor settings. Reusing a key with different settings fails acquisition with `PgBossClientOptionsError` before constructing another client. Its `key`, `options` and message identify the key and differing option names without exposing connection values. Nested options are compared by value.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import { Effect } from "effect";
|
|
263
|
+
import { jobsLayer } from "./runtime.ts";
|
|
264
|
+
import { Jobs } from "./services.ts";
|
|
265
|
+
|
|
266
|
+
const inspectJobs = (databaseUrl: string) =>
|
|
267
|
+
Effect.scoped(
|
|
268
|
+
Effect.gen(function* () {
|
|
269
|
+
const jobs = yield* Jobs;
|
|
270
|
+
return yield* jobs.health;
|
|
271
|
+
}).pipe(Effect.provide(jobsLayer(databaseUrl))),
|
|
272
|
+
);
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
This Effect starts the jobs Layer, reads health and closes the scope. The client stops when that scope closes.
|
|
276
|
+
|
|
277
|
+
### In application code: enqueue
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
import { Effect } from "effect";
|
|
281
|
+
import { SendEmail } from "./jobs.ts";
|
|
282
|
+
import { Jobs } from "./services.ts";
|
|
283
|
+
|
|
284
|
+
export const enqueueEmail = (userId: number) =>
|
|
285
|
+
Effect.gen(function* () {
|
|
286
|
+
const jobs = yield* Jobs;
|
|
287
|
+
return yield* jobs.enqueue(SendEmail, { userId });
|
|
288
|
+
});
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Provide `Jobs` through the application's existing runtime. `enqueue` takes the decoded payload, so passing a string `userId` is a type error. It encodes the number to a string before calling pg-boss. The result type is `Option.Option<string>`: a job identifier becomes `Some`, and a `null` send result becomes `None`.
|
|
292
|
+
|
|
293
|
+
`PgBossPayloadError` names a payload boundary failure; malformed durable input is rejected before the worker's domain code runs.
|
|
294
|
+
|
|
295
|
+
### Trace jobs
|
|
296
|
+
|
|
297
|
+
Each queue or scheduled job attempt runs in one root consumer span named for its queue. The span encloses payload decoding and the whole handler, so top-level `Effect.fn` steps are children of the job span. Its attributes include `job.id`, `job.queue`, `job.name`, the one-based `job.attempt` and pg-boss's zero-based `job.retry_count`. A failed handler ends the span with its failure.
|
|
298
|
+
|
|
299
|
+
`enqueue` captures the current Effect span, including an external parent span, when present. It stores that context in a typed envelope in pg-boss's existing job `data`; the adapter unwraps the envelope before Schema decoding in the handler and `getJob`, so the application's payload Schema stays unchanged. Jobs sent without a span keep their ordinary encoded payload, and workers also accept those unwrapped jobs. Direct pg-boss data readers see the envelope for traced sends.
|
|
300
|
+
|
|
301
|
+
The consumer span links to the sender rather than sharing its parent trace, following OpenTelemetry messaging conventions for asynchronous processing. Each retry gets its own job trace. Provide the application's Effect tracer when building the worker Layer; the worker uses that captured tracer.
|
|
302
|
+
|
|
303
|
+
### Read health
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
import { Effect } from "effect";
|
|
307
|
+
import { Jobs } from "./services.ts";
|
|
308
|
+
|
|
309
|
+
export const readJobCounts = Effect.gen(function* () {
|
|
310
|
+
const jobs = yield* Jobs;
|
|
311
|
+
const health = yield* jobs.health;
|
|
312
|
+
return {
|
|
313
|
+
active: health.activeTotal,
|
|
314
|
+
deadLettered: health.deadLetteredTotal,
|
|
315
|
+
failed: health.failedTotal,
|
|
316
|
+
queued: health.queuedTotal,
|
|
317
|
+
ready: health.readyTotal,
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`health` exposes pg-boss cached queue counts as data. It stays cheap and follows the pg-boss monitor interval; it does not establish that a queue has just drained. Its total fields include the registered queue's active, failed, queued and ready counts, plus the dead-letter queue's queued count. Decide in the application which values affect an HTTP health response or an alert.
|
|
323
|
+
|
|
324
|
+
### Read live counts and retry a job
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import { Effect, Option } from "effect";
|
|
328
|
+
import { SendEmail } from "./jobs.ts";
|
|
329
|
+
import { Jobs } from "./services.ts";
|
|
330
|
+
|
|
331
|
+
export const inspectEmail = (id: string) => Effect.gen(function* () {
|
|
332
|
+
const jobs = yield* Jobs;
|
|
333
|
+
const job = yield* jobs.getJob(SendEmail, id);
|
|
334
|
+
if (Option.isSome(job) && job.value.state === "failed") {
|
|
335
|
+
yield* jobs.retry(SendEmail, id);
|
|
336
|
+
}
|
|
337
|
+
const counts = yield* jobs.counts(SendEmail);
|
|
338
|
+
return counts.queuedCount === 0 && counts.activeCount === 0;
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`counts(queue)` reads the job table directly on each call. `queuedCount` includes created and retry-waiting jobs, including future jobs; `activeCount` counts running jobs, `failedCount` counts retained failures, and `totalCount` includes retained terminal jobs. A queue has drained when queued and active counts are both zero. These counts are a snapshot, so a producer can enqueue more work after the query returns.
|
|
343
|
+
|
|
344
|
+
`getJob(queue, id)` returns `None` when the ID is absent, or `Some` pg-boss job metadata with `data` decoded by the queue's Schema. Invalid stored data fails with `PgBossPayloadError`, and the Effect requires the Schema's decoding services. `retry(queue, id)` reuses the original ID: it makes a failed job eligible for another attempt and pulls a retry-waiting job forward to run now. Missing IDs and jobs in other states are left unchanged. It does not remove an earlier copy from the dead-letter queue.
|
|
345
|
+
|
|
346
|
+
### List failed and dead-lettered jobs
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
import { Effect, Option } from "effect";
|
|
350
|
+
import { SendEmail } from "./jobs.ts";
|
|
351
|
+
import { Jobs } from "./services.ts";
|
|
352
|
+
|
|
353
|
+
export const failedEmails = (cursor?: string) => Effect.gen(function* () {
|
|
354
|
+
const jobs = yield* Jobs;
|
|
355
|
+
const failed = yield* jobs.failedJobs(SendEmail, { cursor, limit: 20 });
|
|
356
|
+
const deadLettered = yield* jobs.deadLetteredJobs(SendEmail, { limit: 20 });
|
|
357
|
+
return {
|
|
358
|
+
deadLettered: deadLettered.jobs.map((job) => ({ id: job.id, original: Option.getOrNull(job.sourceId), error: job.sourceOutput })),
|
|
359
|
+
failed: failed.jobs.map((job) => ({ id: job.id, attempt: job.attempt, error: job.output, userId: job.data.userId })),
|
|
360
|
+
next: Option.getOrUndefined(failed.next),
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
`health` and `counts` say how many jobs failed; these two listings say which ones and why. `failedJobs(queue, options)` lists the queue's jobs in the `failed` state, the ones that used up their retries. `deadLetteredJobs(queue, options)` lists every job in the queue's dead-letter queue. Dead-letter entries live in the `-dlq` queue: each is a new job with its own ID that pg-boss copied there when the original failed for good. Both listings read pg-boss's `job` table on each call, through the jobs Layer's own pg-boss connection and in its configured schema, so applications never query pg-boss tables themselves. A custom client needs the native `getDb` capability for them.
|
|
366
|
+
|
|
367
|
+
Each page holds at most `limit` jobs, newest first by creation time; a dead-letter copy is created when its original fails for good. `limit` must be a positive integer. When more jobs remain, `next` is `Some` opaque cursor; pass it back as `cursor` for the following page, and stop when `next` is `None`. Paging continues from the last job it returned, so jobs that fail while you page appear in a fresh listing instead of shifting later pages, and no job appears twice. A malformed cursor or limit fails with `PgBossError` whose `operation` names the listing.
|
|
368
|
+
|
|
369
|
+
An entry carries the job's `id`, `queue`, native `state`, `retryCount`, `retryLimit` and `attempt`, the one-based number of its last started attempt (zero for a job that never started). `createdOn` is a UTC `DateTime`; `startedOn` and `completedOn` are `Option`s of UTC `DateTime`s. `output` is the job's stored output as pg-boss recorded it: for a failed job, the error its last attempt failed with, and `null` when pg-boss stored none. `data` is decoded by the queue's Schema, including traced payloads; invalid stored data fails the listing with `PgBossPayloadError`, as `getJob` does, and the Effect requires the Schema's decoding services. A dead-letter entry adds `sourceId`, the original job's ID as an `Option`, and `sourceOutput`, the error the original failed with. The copy has not run, so its own `output` stays `null` and its `attempt` is zero.
|
|
370
|
+
|
|
371
|
+
Pass a failed entry's `id` to `retry(queue, id)` to run that job again under its original ID; once it completes it leaves the failed listing. Dead-letter IDs belong to the `-dlq` queue, so `retry(queue, id)` does not find them and leaves them unchanged.
|
|
372
|
+
|
|
373
|
+
### Queue settings on startup
|
|
374
|
+
|
|
375
|
+
`queue` accepts pg-boss `Queue` options except `name` and `deadLetter`, which the definition and adapter own, plus the adapter’s `maxDeferrals` limit. For prompt delivery, set `queue: { notify: true }` and enable `useListenNotify: true` in the worker Layer's constructor options.
|
|
376
|
+
|
|
377
|
+
Startup creates missing queues and reconciles declared settings on existing queues through `updateQueue`. Unspecified settings keep their stored values; the adapter always supplies its default retry settings and dead-letter destination. Changing an explicitly declared `policy` or `partition` on an existing queue fails startup with `PgBossQueueOptionsError`; its `queue`, `options` and message identify the conflict. Queue setting changes apply to subsequently enqueued jobs. Schedule queues follow the same registration path.
|
|
378
|
+
|
|
379
|
+
### Share a client between Layer builds
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
import { Layer } from "effect";
|
|
383
|
+
import { cleanupWorker, emailWorker } from "./jobs.ts";
|
|
384
|
+
import { Jobs, MailLive } from "./services.ts";
|
|
385
|
+
|
|
386
|
+
const sharedJobsLayer = (databaseUrl: string) =>
|
|
387
|
+
Jobs.layer({
|
|
388
|
+
connectionString: databaseUrl,
|
|
389
|
+
schema: "app_jobs",
|
|
390
|
+
clientCacheKey: "app-jobs",
|
|
391
|
+
jobs: [emailWorker, cleanupWorker],
|
|
392
|
+
}).pipe(Layer.provide(MailLive));
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Overlapping Layer builds with the same `clientCacheKey` share one started client. The later build replaces the registered worker, and the final scope release stops the shared client. A later build after that release starts a new client. Without a key, each build creates its own client. The application chooses whether to pass a key; give each distinct constructor configuration its own key.
|
|
396
|
+
|
|
397
|
+
### Separate producers and workers
|
|
398
|
+
|
|
399
|
+
Pass a queue definition directly in the jobs Layer to create its queue and dead-letter queue without starting a consumer:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
import { SendEmail } from "./jobs.ts";
|
|
403
|
+
import { Jobs } from "./services.ts";
|
|
404
|
+
|
|
405
|
+
export const producerLayer = (databaseUrl: string) =>
|
|
406
|
+
Jobs.layer({ connectionString: databaseUrl, jobs: [SendEmail] });
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Provide this Layer to the request runtime and call `jobs.enqueue(SendEmail, { userId })` as above. A separate worker process registers `SendEmail.handle(...)` against the same database. Producer queues use the same retry options and appear in `health`; only worker registrations capture handler services.
|
|
410
|
+
|
|
411
|
+
### Enqueue follow-ups from a handler
|
|
412
|
+
|
|
413
|
+
A handler can yield the same `Jobs` service that owns it. The Layer supplies that service from its scoped client; other handler and Schema service requirements remain required inputs to the Layer.
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
const Receipt = defineQueue({
|
|
417
|
+
name: "receipt",
|
|
418
|
+
schema: Schema.Struct({ userId: Schema.Number }),
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const sendAndQueueReceipt = SendEmail.handle(({ userId }) =>
|
|
422
|
+
Effect.gen(function* () {
|
|
423
|
+
const mail = yield* Mail;
|
|
424
|
+
yield* mail.send(userId);
|
|
425
|
+
const jobs = yield* Jobs;
|
|
426
|
+
yield* jobs.enqueue(Receipt, { userId });
|
|
427
|
+
}),
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
const live = Jobs.layer({
|
|
431
|
+
connectionString: databaseUrl,
|
|
432
|
+
jobs: [sendAndQueueReceipt, Receipt],
|
|
433
|
+
}).pipe(Layer.provide(MailLive));
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Include every target queue in `jobs`, as either a worker or a producer-only definition. Startup creates all queues before starting consumers. The example registers `Receipt` for another process to consume. A follow-up target can also have a worker in the same Layer. Scheduled effects can yield `Jobs` too. Keep one jobs Layer in the process's native Layer graph; that Layer owns one client for enqueueing and handling.
|
|
437
|
+
|
|
438
|
+
### Test through the queue contract
|
|
439
|
+
|
|
440
|
+
`testing.ts` gives story kits a controller for one real pg-boss client. Create a fresh controller per runtime, pass its `clientFactory` to the jobs Layer, and keep that Layer scoped as usual. `jobs(queue, options?)` returns native job metadata with `data` decoded through the queue's Schema, including traced payloads. Invalid durable data fails with `PgBossPayloadError`; decoding services remain in the Effect's requirements.
|
|
441
|
+
|
|
442
|
+
This producer story uses the `SendEmail` definition and `Jobs` service from above. `databaseUrl` points to the story's PostgreSQL database:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
import { Context, Layer, Option } from "effect";
|
|
446
|
+
import { expect } from "vitest";
|
|
447
|
+
import { storyKit } from "@shivaedev/test-story/server/storyKit.ts";
|
|
448
|
+
import { makePgBossTesting, testRetryPolicy } from "@shivaedev/jobs/server/testing.ts";
|
|
449
|
+
|
|
450
|
+
const emailStories = storyKit({
|
|
451
|
+
clock: "live",
|
|
452
|
+
*create() {
|
|
453
|
+
const testing = makePgBossTesting({ retryPolicy: testRetryPolicy });
|
|
454
|
+
const context = yield* Layer.build(Jobs.layer({
|
|
455
|
+
connectionString: databaseUrl,
|
|
456
|
+
clientFactory: testing.clientFactory,
|
|
457
|
+
jobs: [SendEmail],
|
|
458
|
+
}));
|
|
459
|
+
return { jobs: Context.get(context, Jobs), testing };
|
|
460
|
+
},
|
|
461
|
+
name: "queued email",
|
|
462
|
+
stages: ["ready"],
|
|
463
|
+
verbs: ({ jobs, testing }) => ({
|
|
464
|
+
enqueue: (userId: number) => jobs.enqueue(SendEmail, { userId }),
|
|
465
|
+
testing,
|
|
466
|
+
}),
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
emailStories.it("decodes the queued email", [], function* ({ enqueue, testing }) {
|
|
470
|
+
const id = Option.getOrThrow(yield* enqueue(42));
|
|
471
|
+
expect(yield* testing.jobs(SendEmail)).toMatchObject([
|
|
472
|
+
{ id, data: { userId: 42 }, state: "created" },
|
|
473
|
+
]);
|
|
474
|
+
});
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Set `retryPolicy: testRetryPolicy` through the controller to override queue and send settings with `retryDelay: 0` and `retryBackoff: false`. A failing worker can then exhaust its native retries without production backoff. The policy applies to subsequently sent jobs; it does not rewrite jobs already stored.
|
|
478
|
+
|
|
479
|
+
`failNextSend(queue)` arms one send failure for that queue. The next valid `enqueue` fails with the usual typed `PgBossError` (`operation: "enqueue"`); the following send proceeds normally.
|
|
480
|
+
|
|
481
|
+
`expireAfter(queue, n)` takes a positive attempt count: configure it before enqueueing to limit subsequent jobs to `n` attempts. Each successful send keeps its own expiry rule; later changes and duplicate-ID sends do not alter pending jobs. Earlier attempts run the handler normally; on attempt `n`, the controller expires the claimed job instead of invoking its handler. PostgreSQL supervision performs the terminal failure and dead lettering. A job that succeeds earlier completes normally.
|
|
482
|
+
|
|
483
|
+
`expireIn(queue, duration)` takes an Effect `Duration.Input`, such as `"1 second"`, and sets the expiry of subsequent sends, rounded up to whole seconds. Durations below one second or nonfinite durations fail with `PgBossTestingError` (`operation: "expireIn"`). Earlier sends retain their expiry, and later calls and duplicate-ID sends do not change stored jobs. It can be combined with `expireAfter`: one sets each attempt's time budget, the other limits attempts. A stuck handler is interrupted at its deadline, and the same worker can fetch its retry after pg-boss fails the attempt. The testing expiry hooks support pg-boss schema version 46.
|
|
484
|
+
|
|
485
|
+
`deferred(queue)` returns waiting deferrals as `{ at: DateTime.Utc, reason: string }[]`. `runDeferredNow(queue)` pulls those jobs forward to the database’s current time and wakes local workers without waiting for their original due time. Read `revision` before reading deferrals and use `waitForChange(revision)` as with other controls.
|
|
486
|
+
|
|
487
|
+
`checkBatchWindow(queue, millisecondsPerJob)` checks that the declared sequential batch fits strictly inside the stored queue expiry window. `schemaVersion` reads the installed version, and `checkSchemaVersion(expected)` fails when it differs. Test control, read and check failures use `PgBossTestingError` with `operation`, optional `queue` and a redacted `original`.
|
|
488
|
+
|
|
489
|
+
`snapshot(queues)` reads the unfinished jobs of several queues in one query, so the result is one consistent PostgreSQL snapshot. It returns jobs in the `created`, `retry`, `active` and `failed` states, never completed ones, oldest first by creation. Each entry has the job's `id`, `queue` (the definition's name), `state`, `startAfter` as a UTC `DateTime` and `data` decoded by that queue's Schema. Only unfinished rows are decoded, so a completed job whose stored data the Schema no longer accepts does not fail the read; invalid unfinished data fails with `PgBossPayloadError`.
|
|
490
|
+
|
|
491
|
+
`waitForIdle` completes when the controller's local workers hold no job in flight and none of their queues has a job ready to fetch. A job is in flight while it is active in a local worker's queue. A terminal native worker timeout lets the wait complete after pg-boss fails the job. A job whose `startAfter` is in the future does not hold the wait open, and neither do queues without a local worker. While a fetched batch has no settlement yet, the wait reads nothing and waits for the next local change; otherwise it checks with one query per change. A kit settles by yielding `waitForIdle` and then reading jobs once.
|
|
492
|
+
|
|
493
|
+
A kit can also capture `revision` before reading jobs and yield `waitForChange(revision)` when its condition is unmet. The wait resumes after a local send, settlement or worker fetch, and when a local worker returns to idle as `waitForIdle` defines it, including terminal native worker timeouts. It also resumes immediately if a change occurred between the read and the wait. This signal observes the controller's client; external clients, future scheduled work and external database writes need their own wake source. Stories should retain a bounded test timeout.
|
|
494
|
+
|
|
495
|
+
### API
|
|
496
|
+
|
|
497
|
+
Import the module that defines the name; the package has no root entry.
|
|
498
|
+
|
|
499
|
+
| Module | Public names |
|
|
500
|
+
| --- | --- |
|
|
501
|
+
| `@shivaedev/jobs/server/definition.ts` | `defineQueue`, `defineSchedule`, `jobContext`, `RetryAt`; `DefineQueueOptions`, `DefineScheduleOptions`, `JobContext`, `JobPayloadSchema`, `JobRegistration`, `QueueDefinition`, `QueueOptions`, `QueuePolicy`, `QueueWorker`, `RegistrationRequirements`, `ScheduleDefinition`, `ScheduledWorker` |
|
|
502
|
+
| `@shivaedev/jobs/server/service.ts` | `makePgBoss`; `PgBossDefinition`, `PgBossLayerOptions`, `PgBossService` |
|
|
503
|
+
| `@shivaedev/jobs/server/schema.ts` | `installPgBossSchema`, `pgBossSchema`; `PgBossSchemaOptions` |
|
|
504
|
+
| `@shivaedev/jobs/server/health.ts` | `deadLetterQueueName`; `DeadLetteredJob`, `JobPage`, `JobPageOptions`, `JobsHealth`, `ListedJob`, `QueueCounts`, `QueueHealth` |
|
|
505
|
+
| `@shivaedev/jobs/server/error.ts` | `PgBossError`, `PgBossPayloadError`, `PgBossQueueOptionsError`, `PgBossClientOptionsError`, `PgBossTestingError`, `toPgBossError`; `PgBossOperation` |
|
|
506
|
+
| `@shivaedev/jobs/server/client.ts` | `defaultClientFactory`; `PgBossClient`, `PgBossClientFactory` |
|
|
507
|
+
| `@shivaedev/jobs/server/testing.ts` | `makePgBossTesting`, `testRetryPolicy`; `PgBossTesting`, `PgBossTestingOptions`, `SnapshotQueue`, `UnfinishedJob` |
|
|
508
|
+
|
|
509
|
+
The definition options keep the pg-boss names: `queue` uses `QueueOptions` (`Queue` except `name` and `deadLetter`, with literal `QueuePolicy` values and `maxDeferrals`), `worker` uses `WorkOptions`, and a schedule's `schedule` uses `ScheduleOptions`. A queue also requires `name` and `schema`; a schedule requires `name` and `cron`.
|
|
510
|
+
|
|
511
|
+
`Jobs.layer(options)` accepts pg-boss `ConstructorOptions` plus the adapter's own options. Custom clients must supply native `complete` and `fail` capabilities to register queue or schedule workers; missing settlement capabilities fail Layer acquisition with a registration `PgBossError`. Producer-only clients do not need them. Custom clients can also supply the optional native capabilities `findJobs`, `retry`, `update`, `getDb` and `updateQueue` for lookup, retry, live counts and listings, deferral and reconciliation; a requested operation fails with `PgBossError` if its required capability is absent.
|
|
512
|
+
|
|
513
|
+
`onError` handles pg-boss client `error` events using the services captured by the Layer. Only the owning jobs service is supplied automatically; a handler using another jobs definition still requires that definition's Layer.
|
|
514
|
+
|
|
515
|
+
Worker registration always requests `includeMetadata: true`, including when `worker` supplies `includeMetadata: false`; custom clients must return pg-boss's `startedOn` metadata. When calling `jobContext` directly, reading `remainingBudget` requires that same metadata.
|
|
516
|
+
|
|
517
|
+
`PgBossError` has `operation`, optional `queue` and a `Redacted` `original`. Its message names the operation, the queue and the original error's class and `code`, such as a PostgreSQL SQLSTATE, but never the original message, which can quote payloads or connection strings. A registration failure names the queue it was preparing. `PgBossPayloadError` has `direction`, `queue` and a `Redacted` `original`. Those fields distinguish the pg-boss operation from the payload boundary.
|
|
518
|
+
|
|
519
|
+
### Install, setup and limits
|
|
520
|
+
|
|
521
|
+
```sh
|
|
522
|
+
pnpm add @shivaedev/jobs effect@4.0.5 pg-boss@12.37.0
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
The package requires Node.js 24 or later and PostgreSQL. `effect` and `pg-boss` are peers; use versions that satisfy the package's published peer ranges.
|
|
526
|
+
|
|
527
|
+
`connectionString` configures pg-boss's own `pg` Pool, separately from the application's Effect SQL pool. In the default configuration a URL without `sslmode` uses plaintext. Outside a private network, add `sslmode=verify-full` (or `require`) to require TLS. The installed `pg` 8.23 driver verifies certificates for both modes and refuses servers without TLS. For private CAs, start Node with `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`. See the [framework database connection security guide](https://github.com/ShivaeDev/platform/blob/main/docs/framework/quickstart.md#database-connection-security) for all three connection paths and the driver's differences from libpq.
|
|
528
|
+
|
|
529
|
+
Provide the database connection and job registrations at the application runtime boundary. Application services own the domain work and provider calls; pg-boss owns queue execution. Treat enqueueing and an application database transaction as separate operations. Enqueue after the application write commits, and handle queue failures in the application so they cannot roll back the write. The package supplies no application transaction adapter.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ConstructorOptions, FindJobsOptions, Job, JobWithMetadata, PgBoss, Queue, QueueResult, ScheduleOptions, SendOptions, StopOptions, WorkOptions } from "pg-boss";
|
|
2
|
+
export interface PgBossClient {
|
|
3
|
+
readonly complete?: PgBoss["complete"];
|
|
4
|
+
readonly createQueue: (name: string, options?: Omit<Queue, "name">) => Promise<void>;
|
|
5
|
+
readonly fail?: PgBoss["fail"];
|
|
6
|
+
readonly findJobs?: (name: string, options?: FindJobsOptions) => Promise<readonly JobWithMetadata<unknown>[]>;
|
|
7
|
+
readonly getDb?: PgBoss["getDb"];
|
|
8
|
+
readonly getQueue: (name: string) => Promise<QueueResult | null>;
|
|
9
|
+
readonly getWipData?: PgBoss["getWipData"];
|
|
10
|
+
readonly notifyWorker?: PgBoss["notifyWorker"];
|
|
11
|
+
readonly off: (event: "error", listener: (error: Error) => void) => unknown;
|
|
12
|
+
readonly offWork: (name: string) => Promise<void>;
|
|
13
|
+
readonly on: (event: "error", listener: (error: Error) => void) => unknown;
|
|
14
|
+
readonly retry?: PgBoss["retry"];
|
|
15
|
+
readonly schedule: (name: string, cron: string, data?: object | null, options?: ScheduleOptions) => Promise<void>;
|
|
16
|
+
readonly send: (name: string, data?: object | null, options?: SendOptions) => Promise<string | null>;
|
|
17
|
+
readonly start: () => Promise<unknown>;
|
|
18
|
+
readonly stop: (options?: StopOptions) => Promise<void>;
|
|
19
|
+
readonly update?: PgBoss["update"];
|
|
20
|
+
readonly updateQueue?: PgBoss["updateQueue"];
|
|
21
|
+
readonly work: (name: string, options: WorkOptions, handler: (jobs: readonly Job<unknown>[]) => Promise<unknown>) => Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
export type PgBossClientFactory = (options: ConstructorOptions) => PgBossClient;
|
|
24
|
+
export declare const defaultClientFactory: PgBossClientFactory;
|
|
25
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/server/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,kBAAkB,EAClB,eAAe,EACf,GAAG,EACH,eAAe,EACf,MAAM,EACN,KAAK,EACL,WAAW,EACX,eAAe,EACf,WAAW,EACX,WAAW,EACX,WAAW,EACX,MAAM,SAAS,CAAC;AAGjB,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;IACvC,QAAQ,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,KAAK,OAAO,CAAC,SAAS,eAAe,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC9G,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;IACjC,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACjE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,YAAY,CAAC,CAAC;IAC3C,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC,cAAc,CAAC,CAAC;IAC/C,QAAQ,CAAC,GAAG,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,KAAK,OAAO,CAAC;IAC5E,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,QAAQ,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,KAAK,OAAO,CAAC;IAC3E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;IACjC,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,CAAC,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAClH,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACrG,QAAQ,CAAC,KAAK,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACvC,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;IACnC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC,aAAa,CAAC,CAAC;IAC7C,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,CAAC,IAAI,EAAE,SAAS,GAAG,CAAC,OAAO,CAAC,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACrI;AAED,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,EAAE,kBAAkB,KAAK,YAAY,CAAC;AAEhF,eAAO,MAAM,oBAAoB,EAAE,mBAAkC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/server/client.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AA0BzD,MAAM,CAAC,MAAM,oBAAoB,GAAwB,YAAY,CAAC"}
|