@10x-media/jobs 0.1.0-beta.2 → 0.1.0-beta.3

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 (156) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +22 -321
  3. package/dist/execution/autoRunConfig.d.ts +36 -0
  4. package/dist/execution/autoRunConfig.js +25 -0
  5. package/dist/execution/autoRunConfig.js.map +1 -0
  6. package/dist/execution/drain.d.ts +33 -0
  7. package/dist/execution/drain.js +33 -0
  8. package/dist/execution/drain.js.map +1 -0
  9. package/dist/execution/inFlight.js +36 -0
  10. package/dist/execution/inFlight.js.map +1 -0
  11. package/dist/execution/signals.js +31 -0
  12. package/dist/execution/signals.js.map +1 -0
  13. package/dist/execution/worker.d.ts +45 -0
  14. package/dist/execution/worker.js +172 -0
  15. package/dist/execution/worker.js.map +1 -0
  16. package/dist/execution/workerCycles.js +31 -0
  17. package/dist/execution/workerCycles.js.map +1 -0
  18. package/dist/exports/client.d.ts +7 -49
  19. package/dist/exports/client.js +6 -571
  20. package/dist/exports/i18n.d.ts +3 -47
  21. package/dist/exports/i18n.js +2 -2
  22. package/dist/exports/rsc.d.ts +2 -19
  23. package/dist/exports/rsc.js +1 -65
  24. package/dist/exports/types.d.ts +1 -1
  25. package/dist/index.d.ts +36 -2
  26. package/dist/index.js +23 -1744
  27. package/dist/index.js.map +1 -1
  28. package/dist/jobs/JobDocDescription.d.ts +6 -0
  29. package/dist/jobs/JobDocDescription.js +64 -0
  30. package/dist/jobs/JobDocDescription.js.map +1 -0
  31. package/dist/jobs/JobErrorPanel.d.ts +11 -0
  32. package/dist/jobs/JobErrorPanel.js +47 -0
  33. package/dist/jobs/JobErrorPanel.js.map +1 -0
  34. package/dist/jobs/JobLogTimeline.d.ts +12 -0
  35. package/dist/jobs/JobLogTimeline.js +223 -0
  36. package/dist/jobs/JobLogTimeline.js.map +1 -0
  37. package/dist/jobs/JobStatusCell.d.ts +17 -0
  38. package/dist/jobs/JobStatusCell.js +35 -0
  39. package/dist/jobs/JobStatusCell.js.map +1 -0
  40. package/dist/jobs/JobStatusHeader.d.ts +11 -0
  41. package/dist/jobs/JobStatusHeader.js +145 -0
  42. package/dist/jobs/JobStatusHeader.js.map +1 -0
  43. package/dist/jobs/JobsHealthBar.d.ts +19 -0
  44. package/dist/jobs/JobsHealthBar.js +66 -0
  45. package/dist/jobs/JobsHealthBar.js.map +1 -0
  46. package/dist/jobs/RelativeTimeCell.d.ts +10 -0
  47. package/dist/jobs/RelativeTimeCell.js +28 -0
  48. package/dist/jobs/RelativeTimeCell.js.map +1 -0
  49. package/dist/jobs/deriveJobStatus.d.ts +21 -0
  50. package/dist/{deriveJobStatus-CM_sCsgm.js → jobs/deriveJobStatus.js} +2 -2
  51. package/dist/jobs/deriveJobStatus.js.map +1 -0
  52. package/dist/jobs/extractErrorMessage.js +19 -0
  53. package/dist/jobs/extractErrorMessage.js.map +1 -0
  54. package/dist/jobs/formatDuration.js +12 -0
  55. package/dist/jobs/formatDuration.js.map +1 -0
  56. package/dist/jobs/formatRelativeTime.js +53 -0
  57. package/dist/jobs/formatRelativeTime.js.map +1 -0
  58. package/dist/{jobStatusMeta-BLBSPUcE.js → jobs/jobStatusMeta.js} +3 -3
  59. package/dist/jobs/jobStatusMeta.js.map +1 -0
  60. package/dist/options.d.ts +63 -0
  61. package/dist/plugin/registerJobsEnhancements.js +264 -0
  62. package/dist/plugin/registerJobsEnhancements.js.map +1 -0
  63. package/dist/plugin/registerTranslations.js +19 -0
  64. package/dist/plugin/registerTranslations.js.map +1 -0
  65. package/dist/plugin/resolve.d.ts +10 -0
  66. package/dist/plugin/resolve.js +10 -0
  67. package/dist/plugin/resolve.js.map +1 -0
  68. package/dist/presets/presets.d.ts +51 -0
  69. package/dist/presets/presets.js +48 -0
  70. package/dist/presets/presets.js.map +1 -0
  71. package/dist/queueControl/access.d.ts +21 -0
  72. package/dist/queueControl/access.js +25 -0
  73. package/dist/queueControl/access.js.map +1 -0
  74. package/dist/queueControl/endpoints.js +77 -0
  75. package/dist/queueControl/endpoints.js.map +1 -0
  76. package/dist/queueControl/options.d.ts +10 -0
  77. package/dist/queueControl/options.js +15 -0
  78. package/dist/queueControl/options.js.map +1 -0
  79. package/dist/queueControl/pauseState.d.ts +9 -0
  80. package/dist/queueControl/pauseState.js +45 -0
  81. package/dist/queueControl/pauseState.js.map +1 -0
  82. package/dist/queueControl/pauseStore.d.ts +21 -0
  83. package/dist/queueControl/pauseStore.js +31 -0
  84. package/dist/queueControl/pauseStore.js.map +1 -0
  85. package/dist/queueControl/queueHealth.d.ts +53 -0
  86. package/dist/queueControl/queueHealth.js +118 -0
  87. package/dist/queueControl/queueHealth.js.map +1 -0
  88. package/dist/queueControl/registerQueueControl.js +47 -0
  89. package/dist/queueControl/registerQueueControl.js.map +1 -0
  90. package/dist/queueControl/runTargets.js +21 -0
  91. package/dist/queueControl/runTargets.js.map +1 -0
  92. package/dist/reliability/concurrencyContract.d.ts +26 -0
  93. package/dist/reliability/concurrencyContract.js +39 -0
  94. package/dist/reliability/concurrencyContract.js.map +1 -0
  95. package/dist/reliability/fields.js +57 -0
  96. package/dist/reliability/fields.js.map +1 -0
  97. package/dist/reliability/heartbeat.js +103 -0
  98. package/dist/reliability/heartbeat.js.map +1 -0
  99. package/dist/reliability/jobLeaseStore.d.ts +64 -0
  100. package/dist/reliability/jobLeaseStore.js +13 -0
  101. package/dist/reliability/jobLeaseStore.js.map +1 -0
  102. package/dist/reliability/jobLeaseStore.mongo.js +113 -0
  103. package/dist/reliability/jobLeaseStore.mongo.js.map +1 -0
  104. package/dist/reliability/jobLeaseStore.postgres.js +99 -0
  105. package/dist/reliability/jobLeaseStore.postgres.js.map +1 -0
  106. package/dist/reliability/leaderController.d.ts +26 -0
  107. package/dist/reliability/leaderController.js +44 -0
  108. package/dist/reliability/leaderController.js.map +1 -0
  109. package/dist/reliability/leaseLogic.js +7 -0
  110. package/dist/reliability/leaseLogic.js.map +1 -0
  111. package/dist/reliability/leaseMode.js +17 -0
  112. package/dist/reliability/leaseMode.js.map +1 -0
  113. package/dist/reliability/leaseStore.d.ts +39 -0
  114. package/dist/reliability/leaseStore.js +13 -0
  115. package/dist/reliability/leaseStore.js.map +1 -0
  116. package/dist/reliability/leaseStore.mongo.js +66 -0
  117. package/dist/reliability/leaseStore.mongo.js.map +1 -0
  118. package/dist/reliability/leaseStore.postgres.js +67 -0
  119. package/dist/reliability/leaseStore.postgres.js.map +1 -0
  120. package/dist/reliability/locksCollection.d.ts +9 -0
  121. package/dist/reliability/locksCollection.js +49 -0
  122. package/dist/reliability/locksCollection.js.map +1 -0
  123. package/dist/reliability/nodeId.js +12 -0
  124. package/dist/reliability/nodeId.js.map +1 -0
  125. package/dist/reliability/options.d.ts +43 -0
  126. package/dist/reliability/options.js +21 -0
  127. package/dist/reliability/options.js.map +1 -0
  128. package/dist/reliability/recoveryDecision.d.ts +13 -0
  129. package/dist/reliability/recoveryDecision.js +12 -0
  130. package/dist/reliability/recoveryDecision.js.map +1 -0
  131. package/dist/reliability/registerReliability.js +56 -0
  132. package/dist/reliability/registerReliability.js.map +1 -0
  133. package/dist/reliability/sweeper.d.ts +30 -0
  134. package/dist/reliability/sweeper.js +63 -0
  135. package/dist/reliability/sweeper.js.map +1 -0
  136. package/dist/{translations-KyQ3962W.js → translations/en.js} +3 -22
  137. package/dist/translations/en.js.map +1 -0
  138. package/dist/translations/index.d.ts +14 -0
  139. package/dist/translations/index.js +26 -0
  140. package/dist/translations/index.js.map +1 -0
  141. package/dist/translations/keys.d.ts +41 -0
  142. package/dist/{keys-BYs8GTJy.js → translations/keys.js} +2 -2
  143. package/dist/translations/keys.js.map +1 -0
  144. package/dist/{server-JsvYf5vy.js → translations/server.js} +2 -2
  145. package/dist/translations/server.js.map +1 -0
  146. package/dist/translations/useTranslation.js +12 -0
  147. package/dist/translations/useTranslation.js.map +1 -0
  148. package/package.json +5 -4
  149. package/dist/deriveJobStatus-CM_sCsgm.js.map +0 -1
  150. package/dist/exports/client.js.map +0 -1
  151. package/dist/exports/rsc.js.map +0 -1
  152. package/dist/index-LgqeiXr2.d.ts +0 -556
  153. package/dist/jobStatusMeta-BLBSPUcE.js.map +0 -1
  154. package/dist/keys-BYs8GTJy.js.map +0 -1
  155. package/dist/server-JsvYf5vy.js.map +0 -1
  156. package/dist/translations-KyQ3962W.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @10x-media/jobs
2
2
 
3
+ ## 0.1.0-beta.3
4
+
5
+ ### Minor Changes
6
+
7
+ - Add a typed `translations` option to every plugin factory and make translation keys a stable public API. Each plugin's `./i18n` subpath now exports the `keys` object, the `TranslationKey` union, and the `TranslationsOption` shape. Overrides are flat and per-locale: values win over the built-in locales key-by-key, locales a plugin does not ship are added whole, and app-level `i18n.translations` still wins over everything.
8
+
9
+ ```ts
10
+ import { analytics } from "@10x-media/analytics";
11
+ import { keys } from "@10x-media/analytics/i18n";
12
+
13
+ analytics({
14
+ adapters: [nativeAdapter()],
15
+ translations: {
16
+ de: { [keys.pluginName]: "Analytik" },
17
+ },
18
+ });
19
+ ```
20
+
21
+ A typo'd key inside `translations` is a compile error.
22
+
23
+ ### Patch Changes
24
+
25
+ - Restructure README: features, quick start, and links into the documentation site at https://docs.10xmedia.de. Long-form documentation moved out of the package README.
26
+
27
+ - Update README documentation links: the docs site now serves from the domain root, so `docs.10xmedia.de/docs/<plugin>` links became `docs.10xmedia.de/<plugin>`.
28
+
29
+ - Ship per-file dist output instead of bundled chunks. Bundling merged client components into shared chunks and dropped their 'use client' directives, so Next.js lost the RSC boundary and the admin panel crashed with "useRef only works in Client Components" when rendering components imported through such a chunk (for analytics: every chart-based dashboard widget). Dist now mirrors src one file at a time, directives stay exactly where they were authored, and file names are stable across releases. A repo-level `check:dist` verification (directive parity, no inlined dependencies, exports resolution, publint) now runs in CI so this class of regression cannot ship again.
30
+
3
31
  ## 0.1.0-beta.2
4
32
 
5
33
  ### Minor Changes
package/README.md CHANGED
@@ -1,353 +1,54 @@
1
1
  # @10x-media/jobs
2
2
 
3
- A jobs ops dashboard and a production-grade reliability, execution, and queue-control layer for Payload's built-in `payload-jobs`.
3
+ An ops dashboard plus reliability, worker, and queue-control layers for Payload v3's built-in jobs queue. See what the queue is doing, recover jobs that die mid-run, run a proper worker process with graceful drain, and pause or drive the queue from the outside. Every layer is opt-in.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@10x-media/jobs?style=flat-square)](https://www.npmjs.com/package/@10x-media/jobs)
6
6
 
7
7
  Part of the [@10x-media Payload plugins](https://github.com/10x-media/payload-plugins) collection. In beta: published under the `beta` dist-tag until a stable 1.0.
8
8
 
9
- ## Requirements
9
+ ## Features
10
10
 
11
- - Payload v3 (peer: `payload@^3.82.0`)
12
- - React 19 (peer)
11
+ - **Dashboard**: a derived Status column, a queue-health bar, and error/log panels on the `payload-jobs` collection. On by default.
12
+ - **Reliability**: job leases, an orphan sweeper with dead-lettering, leader election with fence tokens, serverless staleness.
13
+ - **Workers**: `createWorker` runs jobs as a standalone process and drains gracefully on SIGTERM.
14
+ - **Queue control**: cluster-wide pause/resume plus hardened `queue-run`, `queue-sweep`, and `queue-status` endpoints with access guards.
15
+ - **Topology presets** for serverless (Vercel Cron), single-node, and multi-node deployments.
16
+ - **Typed translations** with per-key overrides via `@10x-media/jobs/i18n`.
13
17
 
14
- ## Installation
18
+ ## Quick start
15
19
 
16
20
  ```bash
17
21
  pnpm add @10x-media/jobs
18
22
  ```
19
23
 
20
- ## Usage
21
-
22
- ```ts
23
- import { buildConfig } from 'payload'
24
- import { jobs } from '@10x-media/jobs'
25
-
26
- export default buildConfig({
27
- // ...
28
- plugins: [
29
- jobs({
30
- // options
31
- }),
32
- ],
33
- })
34
- ```
35
-
36
- ## Options
37
-
38
- | Option | Type | Default | Description |
39
- |---|---|---|---|
40
- | `disabled` | `boolean` | `false` | When `true`, returns the incoming config unchanged. Useful for toggling the plugin per environment. |
41
- | `reliability` | `ReliabilityOptions \| boolean` | off | Job leases, the orphan sweeper, leader election, and serverless staleness. Opt in with `true` (defaults) or a tuned object. |
42
- | `queueControl` | `QueueControlOptions \| boolean` | off | Cluster-wide pause/resume, hardened run/sweep/status endpoints, and access guards. Opt in with `true` (defaults) or a tuned object. |
43
-
44
- <!-- Add new options to this table as you build them. -->
45
-
46
- ## Deployment topologies
47
-
48
- The plugin ships four opt-in layers. You add only the ones your deployment needs, and you pick a topology preset to wire reliability and queue-control consistently.
49
-
50
- ### Overview
51
-
52
- | Layer | What it adds | How to enable |
53
- |---|---|---|
54
- | Observability | The jobs ops dashboard (status, queue health, error and log panels) and i18n. | Always on (just adding `jobs()`). |
55
- | Reliability | Job leases, an orphan sweeper, leader election, serverless staleness. | `reliability: true` (or a tuned `ReliabilityOptions`). |
56
- | Execution | A standalone worker (`createWorker`) that runs jobs everywhere and schedules/sweeps only while holding the leader lease, with a graceful SIGTERM drain. | Run the worker entrypoint as its own process. |
57
- | Queue control | Cluster-wide pause/resume plus hardened `/api/payload-jobs/queue-run`, `/queue-sweep`, and `/queue-status` endpoints with access guards. | `queueControl: true` (or a tuned `QueueControlOptions`). |
58
-
59
- Every layer is opt-in. The observability dashboard is always present once the plugin is installed. The other three you turn on as your topology demands.
60
-
61
- Three facts shape every topology below.
62
-
63
- 1. **Reliability and queue-control require at least one configured task.** The `payload-jobs` collection only materializes once you configure at least one task. With zero tasks, `createWorker` throws a clear error telling you to add one, and the lease store has no table to read. Configure your tasks on `jobs.tasks` in `buildConfig` before enabling reliability or running a worker.
64
-
65
- 2. **The worker is a separate process, not your web server.** The execution layer (`createWorker`) is meant to run as its own long-lived process (a worker container, a separate Vercel-incompatible service). Your Next.js / Payload web server handles HTTP. The worker boots its own Payload instance against the same database and owns the run/schedule/sweep loops. The only topology that does not run a worker process is serverless, which drives everything from cron-hit endpoints instead.
66
-
67
- 3. **Completed jobs are deleted, not kept.** Payload's `jobs.deleteJobOnComplete` defaults to `true` in v3.85, so a job that finishes successfully is removed from the queue. With the default, the dashboard and `/queue-status` counts reflect the live states (`queued`, `scheduled`, `retrying`, `processing`, `failed`, `cancelled`), since succeeded jobs are gone. Set `jobs.deleteJobOnComplete: false` in `buildConfig` to keep completed jobs for auditing; the `succeeded` count then reflects them.
68
-
69
- Pick a preset:
70
-
71
- ```ts
72
- import { jobs, serverlessPreset, singleNodePreset, multiNodePreset } from '@10x-media/jobs'
73
-
74
- // Serverless (Vercel): cron-driven endpoints, no long-running worker.
75
- jobs({ ...serverlessPreset({ maxDurationMs: 800_000 }) })
76
-
77
- // Single-node Docker: one worker container, claim races moot.
78
- jobs({ ...singleNodePreset() })
79
-
80
- // Multi-node Docker: many worker replicas, one elected scheduler and sweeper.
81
- jobs({ ...multiNodePreset({ leaderId: process.env.HOSTNAME }) })
82
- ```
83
-
84
- Each preset returns `{ reliability, queueControl }`, so spreading it into `jobs({ ... })` configures both layers at once. An override after the spread replaces the whole group (it does not deep-merge), so to tweak one field spread the group too: `jobs({ ...multiNodePreset(), reliability: { ...multiNodePreset().reliability, leaderId: 'node-7' } })`.
85
-
86
- ### Serverless (Vercel)
87
-
88
- Serverless functions are killed at their `maxDuration` with no SIGTERM, so there is no long-running worker and heartbeats are meaningless. `serverlessPreset` instead derives job staleness from the platform hard-kill duration and guards the control endpoints with a shared cron secret. You drive the run and sweep from Vercel Cron.
89
-
90
- ```ts
91
- // payload.config.ts
92
- import { buildConfig } from 'payload'
93
- import { jobs, serverlessPreset } from '@10x-media/jobs'
94
-
95
- export default buildConfig({
96
- // ...
97
- jobs: {
98
- tasks: [
99
- // ...your tasks; at least one is required.
100
- ],
101
- },
102
- plugins: [jobs({ ...serverlessPreset({ maxDurationMs: 800_000 }) })],
103
- })
104
- ```
105
-
106
- `serverlessPreset` sets `reliability.jobLeaseTtlMs` and `reliability.serverless.maxDurationMs` to your `maxDurationMs`, and sets `queueControl.access` to `cronSecretAccess()`. Pass `cronSecretEnvVar` if your secret lives somewhere other than `CRON_SECRET`.
107
-
108
- Generate the `vercel.json` crons with `vercelCrons()`:
109
-
110
- ```ts
111
- // scripts/vercel-crons.ts (or hand-write the array below into vercel.json)
112
- import { vercelCrons } from '@10x-media/jobs'
113
-
114
- console.log(JSON.stringify({ crons: vercelCrons() }, null, 2))
115
- ```
116
-
117
- ```json
118
- {
119
- "crons": [
120
- { "path": "/api/payload-jobs/queue-run?allQueues=true", "schedule": "* * * * *" },
121
- { "path": "/api/payload-jobs/queue-sweep", "schedule": "* * * * *" }
122
- ]
123
- }
124
- ```
125
-
126
- `vercelCrons()` defaults to every minute (Vercel Pro). Override any path or schedule, for example `vercelCrons({ sweepSchedule: '*/5 * * * *' })`.
127
-
128
- **The cron secret.** Set `CRON_SECRET` in your Vercel project. Vercel sends it as `Authorization: Bearer ${CRON_SECRET}` on every cron invocation, and `cronSecretAccess` checks exactly that header (a logged-in admin user also passes, so you can hit the endpoints manually from the panel). Without the secret set, unauthenticated cron requests are rejected.
129
-
130
- **The endpoints.** Both are plugin-registered GET endpoints on the `payload-jobs` collection:
131
-
132
- - `/api/payload-jobs/queue-run` runs due jobs (pause-aware, mirrors the native run params). `?allQueues=true` runs every queue, `?queue=<name>` runs one, `?limit=<n>` caps jobs per invocation, `?disableScheduling=true` skips schedule handling.
133
- - `/api/payload-jobs/queue-sweep` runs one orphan sweep (a single cron invocation, so no leader election). Requires reliability to be enabled.
134
-
135
- **CSRF note.** Both `queue-run` and `queue-sweep` are state-mutating operations served over GET. A crafted `<img>` or link in a document opened by a logged-in admin can trigger them silently (CSRF). The serverless preset uses Vercel Cron, which sends GET requests only, so switching to POST is not feasible without breaking the Vercel cron flow. For deployments where browser sessions are involved, override `access` to require an `Authorization: Bearer` token (`cronSecretAccess`) rather than relying on the session cookie (`loggedInAccess`).
136
-
137
- **Vercel limits.** Match your plan to a cron cadence and a function duration:
138
-
139
- - **Hobby**: crons run at most **once per day** and functions cap at **300s**. That is unusable for real job processing. Use Hobby only for a toy or a demo.
140
- - **Pro**: crons run **per minute** and functions extend to **800s**. That per-minute, 800s window is the practical floor for serverless job processing, which is why `serverlessPreset({ maxDurationMs: 800_000 })` and `vercelCrons()` default to it.
141
-
142
- **`limit` guidance.** A serverless run must finish inside `maxDuration`. Set `?limit=<n>` on the run cron (or `vercelCrons({ runPath: '/api/payload-jobs/queue-run?allQueues=true&limit=20' })`) so one batch of jobs comfortably fits the window. Size the limit to `maxDuration / (slowest expected job duration)` with headroom. If a batch risks overrunning, lower the limit and let the next minute's cron pick up the rest.
143
-
144
- ### Single-node Docker
145
-
146
- One worker container claims and runs every job serially, so claim races are moot and leader election is a no-op (the single node always wins). `singleNodePreset()` turns on reliability and queue-control with defaults; the in-process worker runs the scheduler and sweeper directly.
147
-
148
24
  ```ts
149
25
  // payload.config.ts
150
26
  import { buildConfig } from 'payload'
151
27
  import { jobs, singleNodePreset } from '@10x-media/jobs'
152
28
 
153
29
  export default buildConfig({
154
- // ...
155
30
  jobs: {
156
- tasks: [
157
- // ...your tasks; at least one is required.
158
- ],
31
+ tasks: [/* at least one task; payload-jobs does not exist without one */],
159
32
  },
160
33
  plugins: [jobs({ ...singleNodePreset() })],
161
34
  })
162
35
  ```
163
36
 
164
- Run the worker as its own service in `docker-compose.yml`, alongside your web service and database:
165
-
166
- ```yaml
167
- services:
168
- web:
169
- build: .
170
- command: ['node', 'server.js']
171
- environment:
172
- DATABASE_URI: postgres://postgres:postgres@db:5432/app
173
- PAYLOAD_SECRET: ${PAYLOAD_SECRET}
174
- depends_on: [db]
175
-
176
- worker:
177
- build: .
178
- # Exec-form CMD so Node is PID 1 and receives SIGTERM directly.
179
- command: ['node', 'dist/worker.js']
180
- environment:
181
- DATABASE_URI: postgres://postgres:postgres@db:5432/app
182
- PAYLOAD_SECRET: ${PAYLOAD_SECRET}
183
- depends_on: [db]
184
-
185
- db:
186
- image: postgres:16
187
- environment:
188
- POSTGRES_DB: app
189
- POSTGRES_PASSWORD: postgres
190
- ```
37
+ `jobs({})` alone enables the dashboard; the preset also turns on reliability and queue control. Run a worker process for production execution.
191
38
 
192
- `dist/worker.js` is your compiled worker entrypoint (see below). In development you can run the TypeScript source directly with `node --import tsx worker.ts`. With one claimer, you do not need to tune leader leases; defaults are fine.
39
+ ## Documentation
193
40
 
194
- ### Multi-node Docker
195
-
196
- Many worker replicas share one database. Every replica runs jobs, but only the replica holding the `scheduler` lease handles schedules and only the one holding the `sweeper` lease runs the orphan sweep. `multiNodePreset()` is the default: leader-elected scheduling and sweeping with no extra infrastructure (the leases live in the plugin-owned `payload-jobs-locks` collection).
197
-
198
- ```ts
199
- // payload.config.ts
200
- import { buildConfig } from 'payload'
201
- import { jobs, multiNodePreset } from '@10x-media/jobs'
202
-
203
- export default buildConfig({
204
- // ...
205
- jobs: {
206
- tasks: [
207
- // ...your tasks; at least one is required.
208
- ],
209
- },
210
- // process.env.HOSTNAME is each container's id, a natural stable leader id.
211
- plugins: [jobs({ ...multiNodePreset({ leaderId: process.env.HOSTNAME }) })],
212
- })
213
- ```
214
-
215
- `leaderId` is the stable identity this node uses when it acquires a lease. Pass `process.env.HOSTNAME` (or any per-replica stable value); omit it to let the worker generate a `hostname:pid` identity at runtime. Leadership fails over automatically: if the current leader dies, another replica acquires the lease once it expires (`leaderLeaseTtlMs`, default 30s) and a monotonic fence token prevents a revived zombie from acting.
216
-
217
- **Env-designated-leader fallback (zero infra).** If you do not want leader election at all, you can designate one replica as the scheduler by environment. Run native auto-scheduling on a single replica and disable scheduling on the rest:
218
-
219
- ```ts
220
- import { autoRunConfig } from '@10x-media/jobs'
221
-
222
- const isScheduler = process.env.JOBS_SCHEDULER === '1'
223
-
224
- export default buildConfig({
225
- // ...
226
- jobs: {
227
- tasks: [/* ... */],
228
- // Only the designated replica handles schedules; the others just run jobs.
229
- autoRun: autoRunConfig({ disableScheduling: !isScheduler }),
230
- },
231
- plugins: [jobs({ ...multiNodePreset() })],
232
- })
233
- ```
234
-
235
- Set `JOBS_SCHEDULER=1` on exactly one replica (or run a single dedicated scheduler replica). This trades automatic failover for zero coordination state. Leader election (the default) is preferred when you want a replica loss to recover on its own.
236
-
237
- **Graceful shutdown is a hard requirement under multi-node.** When an orchestrator rolls or scales down a replica, it sends SIGTERM, then SIGKILLs after a grace period. The worker's drain requeues its in-flight job (so another replica picks it up) and releases its leases, but only if it is given time to finish.
238
-
239
- - The orchestrator grace period must be set to `drainTimeoutMs + pollIntervalMs + 5s` to be safe. In Docker Compose set `stop_grace_period`; in Kubernetes set `terminationGracePeriodSeconds`. The actual shutdown time is `drainTimeoutMs` plus up to one `pollIntervalMs` (drain loop overrun) plus the `releaseLeadership` DB roundtrip plus `payload.destroy()` latency, which can be significant if the DB connection pool is busy. If the grace period is shorter, the orchestrator SIGKILLs a still-draining worker and you lose the clean requeue.
240
- - The container `CMD` must be **exec form** (`CMD ["node", "dist/worker.js"]`, not `CMD node dist/worker.js`). Shell form runs Node as a child of `/bin/sh`, which does not forward SIGTERM, so the worker never drains and is hard-killed every time.
241
-
242
- ```yaml
243
- services:
244
- worker:
245
- build: .
246
- command: ['node', 'dist/worker.js'] # exec form: Node receives SIGTERM
247
- # drainTimeoutMs (30s) + pollIntervalMs (0.5s) + 5s buffer = 36s; round up to 40s.
248
- stop_grace_period: 40s
249
- deploy:
250
- replicas: 3
251
- environment:
252
- DATABASE_URI: postgres://postgres:postgres@db:5432/app
253
- PAYLOAD_SECRET: ${PAYLOAD_SECRET}
254
- depends_on: [db]
255
- ```
256
-
257
- The Kubernetes equivalent: an exec-form `command` in the pod spec and `terminationGracePeriodSeconds: 40` (`drainTimeoutMs + pollIntervalMs + 5s` buffer).
258
-
259
- ### The worker entrypoint
260
-
261
- The worker is a thin bootstrap: boot Payload, resolve reliability options, and start the worker. `createWorker` installs SIGTERM/SIGINT drain handlers by default, so the process drains and exits 0 on a real signal. This is the canonical pattern (mirrors `packages/jobs/dev/worker.ts`):
262
-
263
- ```ts
264
- // worker.ts
265
- import { getPayload } from 'payload'
266
- import { createWorker, resolveReliabilityOptions } from '@10x-media/jobs'
267
-
268
- import config from './payload.config'
269
-
270
- const RELIABILITY_OPTIONS = {
271
- jobLeaseTtlMs: 300_000,
272
- leaderLeaseTtlMs: 30_000,
273
- sweepIntervalMs: 60_000,
274
- }
275
-
276
- const main = async (): Promise<void> => {
277
- const payload = await getPayload({ config })
278
- const reliability = resolveReliabilityOptions(RELIABILITY_OPTIONS)
279
- if (!reliability) {
280
- throw new Error('@10x-media/jobs worker: reliability resolved to null')
281
- }
282
- createWorker({
283
- payload,
284
- reliability,
285
- drainTimeoutMs: 30_000,
286
- runIntervalMs: 2_000,
287
- }).start()
288
- payload.logger.info('@10x-media/jobs worker started; awaiting jobs and signals')
289
- }
290
-
291
- main().catch((err) => {
292
- console.error('@10x-media/jobs worker failed to start', err)
293
- process.exit(1)
294
- })
295
- ```
296
-
297
- Notes:
298
-
299
- - `resolveReliabilityOptions` fully defaults your `ReliabilityOptions`. It returns `null` when reliability is off (passed `false` or `undefined`), which the worker cannot run with, hence the guard.
300
- - Pass the same reliability tuning you give the plugin (share a constant between `payload.config.ts` and `worker.ts`) so the lease TTLs match across the cluster.
301
- - `createWorker` registers SIGTERM and SIGINT handlers automatically (`installSignals` defaults to `true`). On signal it drains in-flight jobs (within `drainTimeoutMs`), requeues any straggler, releases leases, destroys the Payload instance, and exits **0** on clean drain or **1** if the drain timed out (jobs were requeued). Keep your orchestrator grace period at `drainTimeoutMs + pollIntervalMs + 5s` (see Multi-node above).
302
- - Only one worker with `installSignals: true` may exist per process. Creating a second one throws. Use `installSignals: false` for additional workers in the same process (e.g. test helpers).
303
- - Run it with `node --import tsx worker.ts` in development, or compile it and run `node dist/worker.js` in production.
304
-
305
- ### CI-optional e2e recipes
306
-
307
- Two real-process scenarios are proven in-process by the test suite, so they are not automated in CI. Both are useful to run by hand against a real database when validating a deployment. Mark them manual / CI-optional.
308
-
309
- **Recipe 1: two workers, one elected scheduler.** Run two worker processes against the same database and confirm exactly one holds the `scheduler` lease.
310
-
311
- ```bash
312
- # Terminal 1 and Terminal 2 (same DATABASE_URI), distinct leader ids:
313
- JOBS_LEADER_ID=node-a node --import tsx worker.ts
314
- JOBS_LEADER_ID=node-b node --import tsx worker.ts
315
- ```
316
-
317
- Then inspect the leases collection (one row per role, `owner` names the current holder):
318
-
319
- ```ts
320
- const locks = await payload.find({
321
- collection: 'payload-jobs-locks',
322
- where: { role: { equals: 'scheduler' } },
323
- })
324
- // Expect exactly one row whose `owner` is node-a OR node-b, never both.
325
- console.log(locks.docs.map((d) => ({ role: d.role, owner: d.owner, fenceToken: d.fenceToken })))
326
- ```
327
-
328
- Only the owning worker logs schedule handling; the other runs jobs but never schedules. (Wire `leaderId` into your worker from `process.env.JOBS_LEADER_ID` for this recipe.)
329
-
330
- **Recipe 2: kill a worker mid-job, watch the sweeper recover the orphan.** Confirm that a hard-killed worker's in-flight job is reclaimed by another worker's sweeper after the lease expires.
331
-
332
- ```bash
333
- # Start two workers against the same DB (as above), then queue a long job:
334
- # await payload.jobs.queue({ task: 'your-slow-task', input: { ... } })
335
- # Find the PID of the worker that claimed it and hard-kill it (no drain):
336
- kill -9 <worker-pid>
337
- ```
338
-
339
- A `kill -9` skips the graceful drain entirely, so the job stays marked processing with a stale lease. After `jobLeaseTtlMs` elapses, the surviving worker's sweeper detects the orphan and requeues it (up to `maxRecoveries` times, then dead-letters). Watch the job flip back to queued and then get re-claimed:
340
-
341
- ```ts
342
- const orphans = await payload.find({
343
- collection: 'payload-jobs',
344
- where: { and: [{ processing: { equals: false } }, { recoveryAttempts: { greater_than: 0 } }] },
345
- })
346
- // After the lease TTL, the killed worker's job appears here with recoveryAttempts >= 1.
347
- console.log(orphans.totalDocs)
348
- ```
41
+ Full documentation at [docs.10xmedia.de](https://docs.10xmedia.de/jobs):
349
42
 
350
- Give it at least `jobLeaseTtlMs + sweepIntervalMs` before asserting recovery.
43
+ - [Overview](https://docs.10xmedia.de/jobs)
44
+ - [Quick start](https://docs.10xmedia.de/jobs/quick-start)
45
+ - [Dashboard](https://docs.10xmedia.de/jobs/dashboard)
46
+ - [Reliability](https://docs.10xmedia.de/jobs/reliability)
47
+ - [Workers](https://docs.10xmedia.de/jobs/workers)
48
+ - [Topologies](https://docs.10xmedia.de/jobs/topologies)
49
+ - [Queue control](https://docs.10xmedia.de/jobs/queue-control)
50
+ - [Testing and local dev](https://docs.10xmedia.de/jobs/testing)
51
+ - [i18n](https://docs.10xmedia.de/jobs/i18n)
351
52
 
352
53
  ## License
353
54
 
@@ -0,0 +1,36 @@
1
+ import { JobsConfig } from "payload";
2
+
3
+ //#region src/execution/autoRunConfig.d.ts
4
+ /**
5
+ * Payload's per-cron autoRun config. Payload 3.85.0 does not re-export the
6
+ * `AutorunCronConfig` type from its package root, so derive it from the array branch of
7
+ * the publicly exported `JobsConfig['autoRun']` (which Payload types as that element).
8
+ */
9
+ type AutorunCronConfig = Extract<NonNullable<JobsConfig['autoRun']>, unknown[]>[number];
10
+ /** One logical queue's autoRun cadence. */
11
+ type AutoRunQueueConfig = {
12
+ queue: string; /** Cron cadence for this queue. Default every minute. */
13
+ cron?: string; /** Max jobs claimed per tick. Default 10. */
14
+ limit?: number;
15
+ };
16
+ type AutoRunConfigOptions = {
17
+ /** One entry per logical queue. Default a single `default` queue. */queues?: AutoRunQueueConfig[]; /** Suppress per-run info logging. Default true. */
18
+ silent?: boolean;
19
+ /**
20
+ * Disable native auto-scheduling on these crons. Default false. Set true when a
21
+ * `createWorker` owns scheduling (multi-node), so the cron only runs jobs.
22
+ */
23
+ disableScheduling?: boolean;
24
+ };
25
+ /**
26
+ * Build a production `jobs.autoRun` array: one Croner config per queue, silent by
27
+ * default, with Payload's own `protect: true` preventing overlap. This is the simple
28
+ * single-node and serverless-adjacent path where native autoRun safely handles both
29
+ * scheduling and running in one process. Multi-node deployments use `createWorker`
30
+ * instead, because native autoRun cannot gate scheduling to one elected leader (its
31
+ * only dynamic lever, `shouldAutoRun`, permanently stops the cron rather than pausing).
32
+ */
33
+ declare const autoRunConfig: (options?: AutoRunConfigOptions) => AutorunCronConfig[];
34
+ //#endregion
35
+ export { AutoRunConfigOptions, AutoRunQueueConfig, autoRunConfig };
36
+ //# sourceMappingURL=autoRunConfig.d.ts.map
@@ -0,0 +1,25 @@
1
+ //#region src/execution/autoRunConfig.ts
2
+ const DEFAULT_CRON = "* * * * *";
3
+ const DEFAULT_LIMIT = 10;
4
+ /**
5
+ * Build a production `jobs.autoRun` array: one Croner config per queue, silent by
6
+ * default, with Payload's own `protect: true` preventing overlap. This is the simple
7
+ * single-node and serverless-adjacent path where native autoRun safely handles both
8
+ * scheduling and running in one process. Multi-node deployments use `createWorker`
9
+ * instead, because native autoRun cannot gate scheduling to one elected leader (its
10
+ * only dynamic lever, `shouldAutoRun`, permanently stops the cron rather than pausing).
11
+ */
12
+ const autoRunConfig = (options = {}) => {
13
+ const { disableScheduling = false, queues = [{ queue: "default" }], silent = true } = options;
14
+ return queues.map((q) => ({
15
+ cron: q.cron ?? DEFAULT_CRON,
16
+ disableScheduling,
17
+ limit: q.limit ?? DEFAULT_LIMIT,
18
+ queue: q.queue,
19
+ silent
20
+ }));
21
+ };
22
+ //#endregion
23
+ export { autoRunConfig };
24
+
25
+ //# sourceMappingURL=autoRunConfig.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"autoRunConfig.js","names":[],"sources":["../../src/execution/autoRunConfig.ts"],"sourcesContent":["import type { JobsConfig } from 'payload'\n\n/**\n * Payload's per-cron autoRun config. Payload 3.85.0 does not re-export the\n * `AutorunCronConfig` type from its package root, so derive it from the array branch of\n * the publicly exported `JobsConfig['autoRun']` (which Payload types as that element).\n */\nexport type AutorunCronConfig = Extract<NonNullable<JobsConfig['autoRun']>, unknown[]>[number]\n\n/** One logical queue's autoRun cadence. */\nexport type AutoRunQueueConfig = {\n\tqueue: string\n\t/** Cron cadence for this queue. Default every minute. */\n\tcron?: string\n\t/** Max jobs claimed per tick. Default 10. */\n\tlimit?: number\n}\n\nexport type AutoRunConfigOptions = {\n\t/** One entry per logical queue. Default a single `default` queue. */\n\tqueues?: AutoRunQueueConfig[]\n\t/** Suppress per-run info logging. Default true. */\n\tsilent?: boolean\n\t/**\n\t * Disable native auto-scheduling on these crons. Default false. Set true when a\n\t * `createWorker` owns scheduling (multi-node), so the cron only runs jobs.\n\t */\n\tdisableScheduling?: boolean\n}\n\nconst DEFAULT_CRON = '* * * * *'\nconst DEFAULT_LIMIT = 10\n\n/**\n * Build a production `jobs.autoRun` array: one Croner config per queue, silent by\n * default, with Payload's own `protect: true` preventing overlap. This is the simple\n * single-node and serverless-adjacent path where native autoRun safely handles both\n * scheduling and running in one process. Multi-node deployments use `createWorker`\n * instead, because native autoRun cannot gate scheduling to one elected leader (its\n * only dynamic lever, `shouldAutoRun`, permanently stops the cron rather than pausing).\n */\nexport const autoRunConfig = (options: AutoRunConfigOptions = {}): AutorunCronConfig[] => {\n\tconst { disableScheduling = false, queues = [{ queue: 'default' }], silent = true } = options\n\treturn queues.map((q) => ({\n\t\tcron: q.cron ?? DEFAULT_CRON,\n\t\tdisableScheduling,\n\t\tlimit: q.limit ?? DEFAULT_LIMIT,\n\t\tqueue: q.queue,\n\t\tsilent,\n\t}))\n}\n"],"mappings":";AA8BA,MAAM,eAAe;AACrB,MAAM,gBAAgB;;;;;;;;;AAUtB,MAAa,iBAAiB,UAAgC,CAAC,MAA2B;CACzF,MAAM,EAAE,oBAAoB,OAAO,SAAS,CAAC,EAAE,OAAO,UAAU,CAAC,GAAG,SAAS,SAAS;CACtF,OAAO,OAAO,KAAK,OAAO;EACzB,MAAM,EAAE,QAAQ;EAChB;EACA,OAAO,EAAE,SAAS;EAClB,OAAO,EAAE;EACT;CACD,EAAE;AACH"}
@@ -0,0 +1,33 @@
1
+ //#region src/execution/drain.d.ts
2
+ type DrainDeps = {
3
+ /** Stop the worker's interval loops (stop claiming new jobs). */stopLoops: () => void; /** Count this node's in-flight jobs (processing and claimed by it). */
4
+ countInFlight: () => Promise<number>; /** Requeue this node's remaining in-flight jobs. Returns how many were released. */
5
+ requeueStragglers: () => Promise<number>; /** Release the held scheduler and sweeper leadership leases. */
6
+ releaseLeadership: () => Promise<void>; /** Destroy the Payload instance (stops crons, closes the DB). */
7
+ destroy: () => Promise<void>; /** Wall-clock milliseconds (real in production, virtual in tests). */
8
+ now: () => number; /** Wait `ms` (real in production, virtual in tests). */
9
+ sleep: (ms: number) => Promise<void>;
10
+ logger?: {
11
+ info?: (m: string) => void;
12
+ };
13
+ };
14
+ type DrainOptions = {
15
+ /** Max wall-clock time to await in-flight jobs before requeuing stragglers. */drainTimeoutMs: number; /** How often to re-count in-flight jobs while draining. */
16
+ pollIntervalMs: number;
17
+ };
18
+ type DrainResult = {
19
+ inFlightAtStart: number;
20
+ remaining: number;
21
+ requeued: number;
22
+ timedOut: boolean;
23
+ };
24
+ /**
25
+ * Run the graceful-drain sequence: stop claiming, await this node's in-flight jobs up
26
+ * to a wall-clock budget, requeue any stragglers, release leadership, and destroy. The
27
+ * clock (`now`/`sleep`) is injected so tests drive it deterministically without real
28
+ * waiting. Always releases leadership and destroys, even when nothing was in flight.
29
+ */
30
+ declare const drainWorker: (deps: DrainDeps, options: DrainOptions) => Promise<DrainResult>;
31
+ //#endregion
32
+ export { DrainDeps, DrainOptions, DrainResult, drainWorker };
33
+ //# sourceMappingURL=drain.d.ts.map
@@ -0,0 +1,33 @@
1
+ //#region src/execution/drain.ts
2
+ /**
3
+ * Run the graceful-drain sequence: stop claiming, await this node's in-flight jobs up
4
+ * to a wall-clock budget, requeue any stragglers, release leadership, and destroy. The
5
+ * clock (`now`/`sleep`) is injected so tests drive it deterministically without real
6
+ * waiting. Always releases leadership and destroys, even when nothing was in flight.
7
+ */
8
+ const drainWorker = async (deps, options) => {
9
+ deps.stopLoops();
10
+ await deps.releaseLeadership();
11
+ const start = deps.now();
12
+ const inFlightAtStart = await deps.countInFlight();
13
+ let remaining = inFlightAtStart;
14
+ while (remaining > 0 && deps.now() - start < options.drainTimeoutMs) {
15
+ await deps.sleep(options.pollIntervalMs);
16
+ if (deps.now() - start >= options.drainTimeoutMs) break;
17
+ remaining = await deps.countInFlight();
18
+ }
19
+ const timedOut = remaining > 0;
20
+ const requeued = timedOut ? await deps.requeueStragglers() : 0;
21
+ await deps.destroy();
22
+ deps.logger?.info?.(`@10x-media/jobs: drain complete (started ${inFlightAtStart}, requeued ${requeued}, timedOut ${timedOut})`);
23
+ return {
24
+ inFlightAtStart,
25
+ remaining,
26
+ requeued,
27
+ timedOut
28
+ };
29
+ };
30
+ //#endregion
31
+ export { drainWorker };
32
+
33
+ //# sourceMappingURL=drain.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drain.js","names":[],"sources":["../../src/execution/drain.ts"],"sourcesContent":["export type DrainDeps = {\n\t/** Stop the worker's interval loops (stop claiming new jobs). */\n\tstopLoops: () => void\n\t/** Count this node's in-flight jobs (processing and claimed by it). */\n\tcountInFlight: () => Promise<number>\n\t/** Requeue this node's remaining in-flight jobs. Returns how many were released. */\n\trequeueStragglers: () => Promise<number>\n\t/** Release the held scheduler and sweeper leadership leases. */\n\treleaseLeadership: () => Promise<void>\n\t/** Destroy the Payload instance (stops crons, closes the DB). */\n\tdestroy: () => Promise<void>\n\t/** Wall-clock milliseconds (real in production, virtual in tests). */\n\tnow: () => number\n\t/** Wait `ms` (real in production, virtual in tests). */\n\tsleep: (ms: number) => Promise<void>\n\tlogger?: { info?: (m: string) => void }\n}\n\nexport type DrainOptions = {\n\t/** Max wall-clock time to await in-flight jobs before requeuing stragglers. */\n\tdrainTimeoutMs: number\n\t/** How often to re-count in-flight jobs while draining. */\n\tpollIntervalMs: number\n}\n\nexport type DrainResult = {\n\tinFlightAtStart: number\n\tremaining: number\n\trequeued: number\n\ttimedOut: boolean\n}\n\n/**\n * Run the graceful-drain sequence: stop claiming, await this node's in-flight jobs up\n * to a wall-clock budget, requeue any stragglers, release leadership, and destroy. The\n * clock (`now`/`sleep`) is injected so tests drive it deterministically without real\n * waiting. Always releases leadership and destroys, even when nothing was in flight.\n */\nexport const drainWorker = async (deps: DrainDeps, options: DrainOptions): Promise<DrainResult> => {\n\tdeps.stopLoops()\n\t// Release leadership before the polling loop so other nodes can elect a new\n\t// leader during the drain window rather than waiting for the full timeout.\n\tawait deps.releaseLeadership()\n\tconst start = deps.now()\n\tconst inFlightAtStart = await deps.countInFlight()\n\tlet remaining = inFlightAtStart\n\twhile (remaining > 0 && deps.now() - start < options.drainTimeoutMs) {\n\t\tawait deps.sleep(options.pollIntervalMs)\n\t\tif (deps.now() - start >= options.drainTimeoutMs) {\n\t\t\tbreak\n\t\t}\n\t\tremaining = await deps.countInFlight()\n\t}\n\tconst timedOut = remaining > 0\n\t// Only on timeout, to abandon still-running handlers. On a clean drain a just-finished\n\t// job can still read processing:true before its completion write lands; requeuing it\n\t// would bump recoveryAttempts and re-run it. The sweeper recovers genuine orphans.\n\tconst requeued = timedOut ? await deps.requeueStragglers() : 0\n\tawait deps.destroy()\n\tdeps.logger?.info?.(\n\t\t`@10x-media/jobs: drain complete (started ${inFlightAtStart}, requeued ${requeued}, timedOut ${timedOut})`\n\t)\n\treturn { inFlightAtStart, remaining, requeued, timedOut }\n}\n"],"mappings":";;;;;;;AAsCA,MAAa,cAAc,OAAO,MAAiB,YAAgD;CAClG,KAAK,UAAU;CAGf,MAAM,KAAK,kBAAkB;CAC7B,MAAM,QAAQ,KAAK,IAAI;CACvB,MAAM,kBAAkB,MAAM,KAAK,cAAc;CACjD,IAAI,YAAY;CAChB,OAAO,YAAY,KAAK,KAAK,IAAI,IAAI,QAAQ,QAAQ,gBAAgB;EACpE,MAAM,KAAK,MAAM,QAAQ,cAAc;EACvC,IAAI,KAAK,IAAI,IAAI,SAAS,QAAQ,gBACjC;EAED,YAAY,MAAM,KAAK,cAAc;CACtC;CACA,MAAM,WAAW,YAAY;CAI7B,MAAM,WAAW,WAAW,MAAM,KAAK,kBAAkB,IAAI;CAC7D,MAAM,KAAK,QAAQ;CACnB,KAAK,QAAQ,OACZ,4CAA4C,gBAAgB,aAAa,SAAS,aAAa,SAAS,EACzG;CACA,OAAO;EAAE;EAAiB;EAAW;EAAU;CAAS;AACzD"}
@@ -0,0 +1,36 @@
1
+ //#region src/execution/inFlight.ts
2
+ /**
3
+ * Create a process-local in-flight counter. One counter is created per worker
4
+ * instance and shared across all heartbeat wrappers running in that worker.
5
+ * Uses a simple closure rather than a DB query, which is cheaper and immune to
6
+ * TOCTOU races on the `claimedBy` field between heartbeat renewals.
7
+ */
8
+ const createInFlightCounter = () => {
9
+ let n = 0;
10
+ return {
11
+ count: () => n,
12
+ decrement: () => {
13
+ n--;
14
+ },
15
+ increment: () => {
16
+ n++;
17
+ }
18
+ };
19
+ };
20
+ const registry = /* @__PURE__ */ new Map();
21
+ /** Register or retrieve the counter for `ownerId`. Creates one on first call. */
22
+ const getOrCreateCounter = (ownerId) => {
23
+ const hit = registry.get(ownerId);
24
+ if (hit) return hit;
25
+ const c = createInFlightCounter();
26
+ registry.set(ownerId, c);
27
+ return c;
28
+ };
29
+ /** Remove the counter for `ownerId` (call on worker teardown to avoid leaks in tests). */
30
+ const releaseCounter = (ownerId) => {
31
+ registry.delete(ownerId);
32
+ };
33
+ //#endregion
34
+ export { getOrCreateCounter, releaseCounter };
35
+
36
+ //# sourceMappingURL=inFlight.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inFlight.js","names":[],"sources":["../../src/execution/inFlight.ts"],"sourcesContent":["/** A process-local counter for jobs currently executing on this node. */\nexport type InFlightCounter = {\n\tincrement: () => void\n\tdecrement: () => void\n\tcount: () => number\n}\n\n/**\n * Create a process-local in-flight counter. One counter is created per worker\n * instance and shared across all heartbeat wrappers running in that worker.\n * Uses a simple closure rather than a DB query, which is cheaper and immune to\n * TOCTOU races on the `claimedBy` field between heartbeat renewals.\n */\nexport const createInFlightCounter = (): InFlightCounter => {\n\tlet n = 0\n\treturn {\n\t\tcount: () => n,\n\t\tdecrement: () => {\n\t\t\tn--\n\t\t},\n\t\tincrement: () => {\n\t\t\tn++\n\t\t},\n\t}\n}\n\n// Registry so the heartbeat wrappers (registered at plugin config time) and the\n// worker (created at runtime) can share the same counter without explicit wiring.\nconst registry = new Map<string, InFlightCounter>()\n\n/** Register or retrieve the counter for `ownerId`. Creates one on first call. */\nexport const getOrCreateCounter = (ownerId: string): InFlightCounter => {\n\tconst hit = registry.get(ownerId)\n\tif (hit) {\n\t\treturn hit\n\t}\n\tconst c = createInFlightCounter()\n\tregistry.set(ownerId, c)\n\treturn c\n}\n\n/** Remove the counter for `ownerId` (call on worker teardown to avoid leaks in tests). */\nexport const releaseCounter = (ownerId: string): void => {\n\tregistry.delete(ownerId)\n}\n"],"mappings":";;;;;;;AAaA,MAAa,8BAA+C;CAC3D,IAAI,IAAI;CACR,OAAO;EACN,aAAa;EACb,iBAAiB;GAChB;EACD;EACA,iBAAiB;GAChB;EACD;CACD;AACD;AAIA,MAAM,2BAAW,IAAI,IAA6B;;AAGlD,MAAa,sBAAsB,YAAqC;CACvE,MAAM,MAAM,SAAS,IAAI,OAAO;CAChC,IAAI,KACH,OAAO;CAER,MAAM,IAAI,sBAAsB;CAChC,SAAS,IAAI,SAAS,CAAC;CACvB,OAAO;AACR;;AAGA,MAAa,kBAAkB,YAA0B;CACxD,SAAS,OAAO,OAAO;AACxB"}
@@ -0,0 +1,31 @@
1
+ //#region src/execution/signals.ts
2
+ let handlersInstalled = false;
3
+ /** Whether signal handlers have been installed in this process (module-level flag). */
4
+ const areHandlersInstalled = () => handlersInstalled;
5
+ /**
6
+ * Register `handler` for each signal and return a cleanup that removes them. The
7
+ * handler fires at most once across all signals (a second signal during drain is
8
+ * ignored). Payload installs no signal handlers of its own, so these never conflict.
9
+ * `target` defaults to `process`; tests pass an EventEmitter.
10
+ */
11
+ const installSignalHandlers = (signals, handler, target = process) => {
12
+ handlersInstalled = true;
13
+ let fired = false;
14
+ const registered = signals.map((signal) => {
15
+ const listener = () => {
16
+ if (fired) return;
17
+ fired = true;
18
+ handler(signal);
19
+ };
20
+ target.on(signal, listener);
21
+ return [signal, listener];
22
+ });
23
+ return () => {
24
+ handlersInstalled = false;
25
+ for (const [signal, listener] of registered) target.removeListener(signal, listener);
26
+ };
27
+ };
28
+ //#endregion
29
+ export { areHandlersInstalled, installSignalHandlers };
30
+
31
+ //# sourceMappingURL=signals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signals.js","names":[],"sources":["../../src/execution/signals.ts"],"sourcesContent":["let handlersInstalled = false\n\n/** Whether signal handlers have been installed in this process (module-level flag). */\nexport const areHandlersInstalled = (): boolean => handlersInstalled\n\n/** Reset the installed-handlers flag. Only for use in tests between worker instantiations. */\nexport const resetHandlersInstalled = (): void => {\n\thandlersInstalled = false\n}\n\n/** The subset of `process` the signal installer needs (injectable for tests). */\nexport type SignalTarget = {\n\ton: (signal: NodeJS.Signals, listener: () => void) => unknown\n\tremoveListener: (signal: NodeJS.Signals, listener: () => void) => unknown\n}\n\nexport type SignalCleanup = () => void\n\n/**\n * Register `handler` for each signal and return a cleanup that removes them. The\n * handler fires at most once across all signals (a second signal during drain is\n * ignored). Payload installs no signal handlers of its own, so these never conflict.\n * `target` defaults to `process`; tests pass an EventEmitter.\n */\nexport const installSignalHandlers = (\n\tsignals: NodeJS.Signals[],\n\thandler: (signal: NodeJS.Signals) => void,\n\ttarget: SignalTarget = process\n): SignalCleanup => {\n\thandlersInstalled = true\n\tlet fired = false\n\tconst registered = signals.map((signal) => {\n\t\tconst listener = () => {\n\t\t\tif (fired) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfired = true\n\t\t\thandler(signal)\n\t\t}\n\t\ttarget.on(signal, listener)\n\t\treturn [signal, listener] as const\n\t})\n\treturn () => {\n\t\thandlersInstalled = false\n\t\tfor (const [signal, listener] of registered) {\n\t\t\ttarget.removeListener(signal, listener)\n\t\t}\n\t}\n}\n"],"mappings":";AAAA,IAAI,oBAAoB;;AAGxB,MAAa,6BAAsC;;;;;;;AAqBnD,MAAa,yBACZ,SACA,SACA,SAAuB,YACJ;CACnB,oBAAoB;CACpB,IAAI,QAAQ;CACZ,MAAM,aAAa,QAAQ,KAAK,WAAW;EAC1C,MAAM,iBAAiB;GACtB,IAAI,OACH;GAED,QAAQ;GACR,QAAQ,MAAM;EACf;EACA,OAAO,GAAG,QAAQ,QAAQ;EAC1B,OAAO,CAAC,QAAQ,QAAQ;CACzB,CAAC;CACD,aAAa;EACZ,oBAAoB;EACpB,KAAK,MAAM,CAAC,QAAQ,aAAa,YAChC,OAAO,eAAe,QAAQ,QAAQ;CAExC;AACD"}