alepha 0.20.4 → 0.20.6
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/dist/api/audits/index.d.ts +391 -359
- package/dist/api/audits/index.d.ts.map +1 -1
- package/dist/api/audits/index.js +23 -1
- package/dist/api/audits/index.js.map +1 -1
- package/dist/api/files/index.d.ts +18 -0
- package/dist/api/files/index.d.ts.map +1 -1
- package/dist/api/files/index.js +51 -0
- package/dist/api/files/index.js.map +1 -1
- package/dist/api/jobs/index.browser.js +33 -14
- package/dist/api/jobs/index.browser.js.map +1 -1
- package/dist/api/jobs/index.d.ts +452 -155
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +474 -159
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/keys/index.d.ts +32 -4
- package/dist/api/keys/index.d.ts.map +1 -1
- package/dist/api/keys/index.js +53 -0
- package/dist/api/keys/index.js.map +1 -1
- package/dist/api/notifications/index.d.ts +29 -1
- package/dist/api/notifications/index.d.ts.map +1 -1
- package/dist/api/notifications/index.js +55 -13
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/organizations/index.js.map +1 -1
- package/dist/api/parameters/index.d.ts +15 -0
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +37 -0
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/api/payments/index.js.map +1 -1
- package/dist/api/users/index.d.ts +150 -9
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +237 -28
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +3 -3
- package/dist/api/verifications/index.js.map +1 -1
- package/dist/batch/index.js.map +1 -1
- package/dist/bin/index.js +0 -0
- package/dist/bucket/index.d.ts +18 -0
- package/dist/bucket/index.d.ts.map +1 -1
- package/dist/bucket/index.js +47 -0
- package/dist/bucket/index.js.map +1 -1
- package/dist/bucket/index.workerd.js +24 -0
- package/dist/bucket/index.workerd.js.map +1 -1
- package/dist/cache/core/index.d.ts +20 -3
- package/dist/cache/core/index.d.ts.map +1 -1
- package/dist/cache/core/index.js.map +1 -1
- package/dist/cache/core/index.workerd.js.map +1 -1
- package/dist/cache/database/index.d.ts +155 -0
- package/dist/cache/database/index.d.ts.map +1 -0
- package/dist/cache/database/index.js +266 -0
- package/dist/cache/database/index.js.map +1 -0
- package/dist/cache/redis/index.js.map +1 -1
- package/dist/captcha/index.js.map +1 -1
- package/dist/cli/config/index.js.map +1 -1
- package/dist/cli/core/index.d.ts +35 -5
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +85 -6
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/devtools/index.js.map +1 -1
- package/dist/cli/platform/index.js +1 -1
- package/dist/cli/platform/index.js.map +1 -1
- package/dist/cli/vendor/index.js.map +1 -1
- package/dist/command/index.js.map +1 -1
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js.map +1 -1
- package/dist/crypto/index.browser.js.map +1 -1
- package/dist/crypto/index.js.map +1 -1
- package/dist/datetime/index.js.map +1 -1
- package/dist/email/brevo/index.js.map +1 -1
- package/dist/email/core/index.js.map +1 -1
- package/dist/email/core/index.workerd.js.map +1 -1
- package/dist/email/smtp/index.js.map +1 -1
- package/dist/fake/index.js.map +1 -1
- package/dist/lock/core/index.js.map +1 -1
- package/dist/lock/redis/index.js.map +1 -1
- package/dist/logger/index.js.map +1 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/orm/core/index.browser.js.map +1 -1
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.js.map +1 -1
- package/dist/orm/postgres/index.bun.js.map +1 -1
- package/dist/orm/postgres/index.js.map +1 -1
- package/dist/queue/core/index.js.map +1 -1
- package/dist/queue/core/index.workerd.js.map +1 -1
- package/dist/queue/redis/index.js.map +1 -1
- package/dist/react/auth/index.browser.js.map +1 -1
- package/dist/react/auth/index.js.map +1 -1
- package/dist/react/core/index.js.map +1 -1
- package/dist/react/form/index.js +2 -0
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/head/index.browser.js.map +1 -1
- package/dist/react/head/index.js.map +1 -1
- package/dist/react/i18n/index.js.map +1 -1
- package/dist/react/intro/index.js.map +1 -1
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.js.map +1 -1
- package/dist/react/testing/index.js.map +1 -1
- package/dist/react/ui/index.js.map +1 -1
- package/dist/react/websocket/index.js.map +1 -1
- package/dist/redis/index.bun.js.map +1 -1
- package/dist/redis/index.js.map +1 -1
- package/dist/retry/index.js.map +1 -1
- package/dist/router/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +22 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +12 -0
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/index.workerd.js +12 -0
- package/dist/scheduler/index.workerd.js.map +1 -1
- package/dist/security/index.browser.js.map +1 -1
- package/dist/security/index.js.map +1 -1
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/cookies/index.browser.js.map +1 -1
- package/dist/server/cookies/index.js.map +1 -1
- package/dist/server/core/index.browser.js.map +1 -1
- package/dist/server/core/index.js.map +1 -1
- package/dist/server/cors/index.js.map +1 -1
- package/dist/server/etag/index.js.map +1 -1
- package/dist/server/health/index.js.map +1 -1
- package/dist/server/links/index.browser.js.map +1 -1
- package/dist/server/links/index.js.map +1 -1
- package/dist/server/metrics/index.js.map +1 -1
- package/dist/server/proxy/index.js.map +1 -1
- package/dist/server/rate-limit/index.js.map +1 -1
- package/dist/server/static/index.js.map +1 -1
- package/dist/server/swagger/index.js.map +1 -1
- package/dist/sms/index.js.map +1 -1
- package/dist/system/index.browser.js.map +1 -1
- package/dist/system/index.js.map +1 -1
- package/dist/system/index.workerd.js.map +1 -1
- package/dist/topic/core/index.js.map +1 -1
- package/dist/topic/redis/index.js.map +1 -1
- package/dist/websocket/index.browser.js +4 -0
- package/dist/websocket/index.browser.js.map +1 -1
- package/dist/websocket/index.js +10 -0
- package/dist/websocket/index.js.map +1 -1
- package/package.json +282 -272
- package/src/api/audits/controllers/AdminAuditController.ts +29 -0
- package/src/api/files/controllers/FileController.ts +24 -0
- package/src/api/files/services/FileService.ts +41 -0
- package/src/api/jobs/__tests__/$job.spec.ts +427 -2
- package/src/api/jobs/entities/jobExecutionEntity.ts +3 -3
- package/src/api/jobs/index.ts +47 -10
- package/src/api/jobs/primitives/$job.ts +22 -9
- package/src/api/jobs/providers/DirectJobDispatcher.ts +71 -0
- package/src/api/jobs/providers/JobDispatcher.ts +49 -0
- package/src/api/jobs/providers/JobProvider.ts +365 -142
- package/src/api/jobs/providers/JobQueueProvider.ts +43 -18
- package/src/api/jobs/schemas/jobConfigAtom.ts +4 -3
- package/src/api/jobs/schemas/jobExecutionResourceSchema.ts +11 -0
- package/src/api/jobs/schemas/jobRegistrationSchema.ts +4 -2
- package/src/api/jobs/services/JobService.ts +21 -11
- package/src/api/keys/controllers/AdminApiKeyController.ts +23 -0
- package/src/api/keys/services/ApiKeyService.ts +42 -0
- package/src/api/notifications/__tests__/AlephaApiNotifications.spec.ts +63 -0
- package/src/api/notifications/controllers/AdminNotificationController.ts +48 -1
- package/src/api/notifications/index.ts +13 -3
- package/src/api/notifications/jobs/NotificationJobs.ts +0 -6
- package/src/api/parameters/controllers/AdminParameterController.ts +26 -0
- package/src/api/parameters/services/ParameterProvider.ts +18 -0
- package/src/api/users/__tests__/Registration-emailMode.spec.ts +203 -0
- package/src/api/users/__tests__/UsernameSlugger.spec.ts +138 -0
- package/src/api/users/atoms/realmAuthSettingsAtom.ts +41 -3
- package/src/api/users/controllers/AdminSessionController.ts +29 -0
- package/src/api/users/controllers/AdminUserController.ts +32 -0
- package/src/api/users/index.ts +3 -0
- package/src/api/users/services/CredentialService.ts +5 -0
- package/src/api/users/services/RegistrationService.ts +49 -1
- package/src/api/users/services/SessionCrudService.ts +16 -0
- package/src/api/users/services/SessionService.ts +17 -59
- package/src/api/users/services/UsernameSlugger.ts +195 -0
- package/src/bucket/primitives/$bucket.ts +21 -0
- package/src/bucket/providers/CloudflareR2Provider.ts +15 -0
- package/src/bucket/providers/FileStorageProvider.ts +9 -0
- package/src/bucket/providers/LocalFileStorageProvider.ts +14 -0
- package/src/bucket/providers/MemoryFileStorageProvider.ts +9 -0
- package/src/bucket/providers/NodeS3BucketProvider.ts +35 -0
- package/src/cache/core/primitives/$cache.ts +20 -3
- package/src/cache/database/__tests__/DatabaseCacheProvider.behavior.spec.ts +203 -0
- package/src/cache/database/__tests__/DatabaseCacheProvider.spec.ts +110 -0
- package/src/cache/database/entities/cacheEntries.ts +55 -0
- package/src/cache/database/index.ts +36 -0
- package/src/cache/database/providers/DatabaseCacheProvider.ts +348 -0
- package/src/cli/core/services/ProjectScaffolder.ts +0 -2
- package/src/cli/core/tasks/BuildCloudflareTask.ts +17 -3
- package/src/cli/core/tasks/BuildSitemapTask.ts +7 -0
- package/src/cli/core/tasks/BuildVercelTask.ts +82 -3
- package/src/cli/platform/__tests__/detectResources.spec.ts +96 -0
- package/src/cli/platform/commands/platform.ts +7 -1
- package/src/scheduler/index.ts +14 -0
- package/src/scheduler/providers/CronProvider.ts +13 -0
package/dist/api/jobs/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { $atom, $hook, $inject, $module, $state, Alepha, AlephaError, KIND, PipelinePrimitive, createPrimitive, t } from "alepha";
|
|
2
|
-
import { AlephaLock } from "alepha/lock";
|
|
2
|
+
import { AlephaLock, LockProvider } from "alepha/lock";
|
|
3
3
|
import { $queue, AlephaQueue } from "alepha/queue";
|
|
4
4
|
import { AlephaScheduler, CronProvider } from "alepha/scheduler";
|
|
5
5
|
import { $secure } from "alepha/security";
|
|
6
6
|
import { $action, NotFoundError, okSchema } from "alepha/server";
|
|
7
7
|
import { $logger, logEntrySchema } from "alepha/logger";
|
|
8
|
-
import { $entity, $repository, db, sql } from "alepha/orm";
|
|
8
|
+
import { $entity, $repository, DbEntityNotFoundError, db, sql } from "alepha/orm";
|
|
9
9
|
import { DateTimeProvider } from "alepha/datetime";
|
|
10
10
|
//#region ../../src/api/jobs/schemas/jobExecutionQuerySchema.ts
|
|
11
11
|
const jobExecutionQuerySchema = t.object({
|
|
@@ -33,11 +33,11 @@ const jobExecutionQuerySchema = t.object({
|
|
|
33
33
|
* the last N rows per job (configurable via `jobConfig.keepLastSuccess`).
|
|
34
34
|
*
|
|
35
35
|
* Status transitions:
|
|
36
|
-
* - queue push → pending
|
|
36
|
+
* - queue push → pending (or `scheduled` if `delay`/`scheduledAt` was given)
|
|
37
37
|
* - worker claim → running
|
|
38
|
-
* - success → ok
|
|
38
|
+
* - success → ok (or row deleted, depending on `record` and `keepLastSuccess`)
|
|
39
39
|
* - terminal failure → error
|
|
40
|
-
* -
|
|
40
|
+
* - retryable failure → scheduled (with scheduledAt = now; sweep picks it up)
|
|
41
41
|
* - delay → scheduled (with scheduledAt = now + delay)
|
|
42
42
|
* - sweep picks due ones → pending
|
|
43
43
|
* - cancel → cancelled
|
|
@@ -90,10 +90,28 @@ const jobExecutionEntity = $entity({
|
|
|
90
90
|
});
|
|
91
91
|
//#endregion
|
|
92
92
|
//#region ../../src/api/jobs/schemas/jobExecutionResourceSchema.ts
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Public-facing schema for a job execution row.
|
|
95
|
+
*
|
|
96
|
+
* Diverges from the raw entity in two places, both for API ergonomics:
|
|
97
|
+
*
|
|
98
|
+
* - `priority` is exposed as the **string enum** (`critical`/`high`/...)
|
|
99
|
+
* instead of the numeric value used internally for SQL ordering. The
|
|
100
|
+
* `JobService` is responsible for the int → string transform.
|
|
101
|
+
* - `can` derives the available admin actions from the row's status.
|
|
102
|
+
*/
|
|
103
|
+
const jobExecutionResourceSchema = t.extend(jobExecutionEntity.schema, {
|
|
104
|
+
priority: t.enum([
|
|
105
|
+
"critical",
|
|
106
|
+
"high",
|
|
107
|
+
"normal",
|
|
108
|
+
"low"
|
|
109
|
+
]),
|
|
110
|
+
can: t.object({
|
|
111
|
+
retry: t.boolean(),
|
|
112
|
+
cancel: t.boolean()
|
|
113
|
+
})
|
|
114
|
+
}, {
|
|
97
115
|
title: "JobExecutionResource",
|
|
98
116
|
description: "A job execution row with derived actions."
|
|
99
117
|
});
|
|
@@ -102,7 +120,11 @@ const jobExecutionResourceSchema = t.extend(jobExecutionEntity.schema, { can: t.
|
|
|
102
120
|
const jobRegistrationSchema = t.object({
|
|
103
121
|
name: t.text(),
|
|
104
122
|
description: t.optional(t.text()),
|
|
105
|
-
type: t.enum([
|
|
123
|
+
type: t.enum([
|
|
124
|
+
"cron",
|
|
125
|
+
"queue",
|
|
126
|
+
"direct"
|
|
127
|
+
], { description: "Effective runtime mode. 'cron' = scheduled. 'queue' = push-driven, dispatched via AlephaApiJobsQueue. 'direct' = push-driven, processed in-process (no queue infrastructure loaded), with the sweep as the safety net." }),
|
|
106
128
|
priority: t.enum([
|
|
107
129
|
"critical",
|
|
108
130
|
"high",
|
|
@@ -111,10 +133,7 @@ const jobRegistrationSchema = t.object({
|
|
|
111
133
|
]),
|
|
112
134
|
cron: t.optional(t.text()),
|
|
113
135
|
timeout: t.optional(t.text()),
|
|
114
|
-
retry: t.optional(t.object({
|
|
115
|
-
retries: t.integer(),
|
|
116
|
-
hasBackoff: t.boolean()
|
|
117
|
-
})),
|
|
136
|
+
retry: t.optional(t.object({ retries: t.integer() })),
|
|
118
137
|
recent: t.object({
|
|
119
138
|
ok: t.integer(),
|
|
120
139
|
error: t.integer(),
|
|
@@ -130,7 +149,7 @@ const jobConfig = $atom({
|
|
|
130
149
|
name: "alepha.jobs",
|
|
131
150
|
description: "Configuration for the $job primitive.",
|
|
132
151
|
schema: t.object({
|
|
133
|
-
|
|
152
|
+
sweepCron: t.text({ description: "Cron expression for the sweep tick. Must be minute-granular at minimum (cron resolution). On Cloudflare Workers this expression is emitted into wrangler.jsonc by the build." }),
|
|
134
153
|
staleThreshold: t.integer({ description: "Pending age (ms) before the sweep re-dispatches it." }),
|
|
135
154
|
runTimeout: t.integer({ description: "Running age (ms) before assumed crash (fallback when no per-job timeout)." }),
|
|
136
155
|
keepLastSuccess: t.integer({ description: "Max successful rows to keep per job. Set 0 to disable and delete on success." }),
|
|
@@ -139,7 +158,7 @@ const jobConfig = $atom({
|
|
|
139
158
|
drainTimeout: t.integer({ description: "Max time (ms) to wait for in-flight jobs during shutdown." })
|
|
140
159
|
}),
|
|
141
160
|
default: {
|
|
142
|
-
|
|
161
|
+
sweepCron: "*/5 * * * *",
|
|
143
162
|
staleThreshold: 3e5,
|
|
144
163
|
runTimeout: 18e5,
|
|
145
164
|
keepLastSuccess: 10,
|
|
@@ -149,6 +168,135 @@ const jobConfig = $atom({
|
|
|
149
168
|
}
|
|
150
169
|
});
|
|
151
170
|
//#endregion
|
|
171
|
+
//#region ../../src/api/jobs/providers/JobDispatcher.ts
|
|
172
|
+
/**
|
|
173
|
+
* Abstract dispatcher for queued/direct job executions.
|
|
174
|
+
*
|
|
175
|
+
* The default implementation, {@link DirectJobDispatcher}, runs the handler
|
|
176
|
+
* in-process after the caller's `push()` returns — fast and dependency-free.
|
|
177
|
+
*
|
|
178
|
+
* `AlephaApiJobsQueue` substitutes this with `JobQueueProvider`, which
|
|
179
|
+
* publishes the executionId to `AlephaQueue` so a worker pool can consume
|
|
180
|
+
* the work asynchronously.
|
|
181
|
+
*
|
|
182
|
+
* Substitute via DI:
|
|
183
|
+
* ```ts
|
|
184
|
+
* Alepha.create()
|
|
185
|
+
* .with({ provide: JobDispatcher, use: MyCustomDispatcher })
|
|
186
|
+
* .with(AlephaApiJobs);
|
|
187
|
+
* ```
|
|
188
|
+
*
|
|
189
|
+
* The `kind` getter is read by the `JobProvider.effectiveMode` accessor
|
|
190
|
+
* and by the admin UI so users can see which dispatcher is currently active.
|
|
191
|
+
*/
|
|
192
|
+
var JobDispatcher = class {
|
|
193
|
+
/**
|
|
194
|
+
* Optional batch dispatch. The default implementation loops, but
|
|
195
|
+
* dispatchers backed by a real queue should override this to use the
|
|
196
|
+
* provider's batch send (e.g. Cloudflare Queues `sendBatch`).
|
|
197
|
+
*/
|
|
198
|
+
async dispatchMany(items) {
|
|
199
|
+
for (const item of items) await this.dispatch(item.jobName, item.executionId);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region ../../src/api/jobs/providers/DirectJobDispatcher.ts
|
|
204
|
+
/**
|
|
205
|
+
* Default `JobDispatcher` for environments without `AlephaApiJobsQueue`.
|
|
206
|
+
*
|
|
207
|
+
* Runs `JobProvider.processExecution` in the background — the caller's
|
|
208
|
+
* `push()` returns immediately while the handler continues to completion
|
|
209
|
+
* in the same process. The DB outbox row is the durability guarantee:
|
|
210
|
+
* if the process dies before the handler finishes, the next sweep tick
|
|
211
|
+
* picks the row up and re-dispatches.
|
|
212
|
+
*
|
|
213
|
+
* **Cloudflare Workers** — when an `executionCtx.waitUntil` is available
|
|
214
|
+
* in the alepha store at `cloudflare.waitUntil`, the dispatch wraps the
|
|
215
|
+
* background promise with `waitUntil` so the runtime keeps the isolate
|
|
216
|
+
* alive past the HTTP response. Without this, the handler would be
|
|
217
|
+
* terminated when the response is returned and only the next sweep
|
|
218
|
+
* (every 5 min by default) would re-dispatch.
|
|
219
|
+
*
|
|
220
|
+
* **Vercel / single-Node** — on long-running runtimes the event loop
|
|
221
|
+
* keeps the promise alive naturally; no special wiring is required.
|
|
222
|
+
*/
|
|
223
|
+
var DirectJobDispatcher = class extends JobDispatcher {
|
|
224
|
+
kind = "direct";
|
|
225
|
+
alepha = $inject(Alepha);
|
|
226
|
+
log = $logger();
|
|
227
|
+
jobProviderRef;
|
|
228
|
+
getJobProvider() {
|
|
229
|
+
if (!this.jobProviderRef) this.jobProviderRef = this.alepha.inject(JobProvider);
|
|
230
|
+
return this.jobProviderRef;
|
|
231
|
+
}
|
|
232
|
+
async dispatch(jobName, executionId) {
|
|
233
|
+
const promise = this.getJobProvider().processExecution(jobName, executionId).catch((err) => {
|
|
234
|
+
this.log.warn(`Direct execution failed for '${jobName}' (sweep will retry)`, err);
|
|
235
|
+
});
|
|
236
|
+
const waitUntil = this.alepha.store.get("cloudflare.waitUntil");
|
|
237
|
+
if (typeof waitUntil === "function") try {
|
|
238
|
+
waitUntil(promise);
|
|
239
|
+
} catch (e) {
|
|
240
|
+
this.log.debug("waitUntil rejected — falling back to fire-and-track", e);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region ../../src/api/jobs/providers/JobQueueProvider.ts
|
|
246
|
+
/**
|
|
247
|
+
* Queue-backed `JobDispatcher` registered by `AlephaApiJobsQueue`.
|
|
248
|
+
*
|
|
249
|
+
* Extends {@link JobDispatcher} and substitutes the default
|
|
250
|
+
* `DirectJobDispatcher` so that `$job.push()` is delivered through
|
|
251
|
+
* `AlephaQueue` (e.g. Cloudflare Queues, Redis, in-memory) instead of
|
|
252
|
+
* being processed in-process.
|
|
253
|
+
*
|
|
254
|
+
* The class is also kept as a `JobQueueProvider` export name for backwards
|
|
255
|
+
* compatibility — it has always been the queue path's entry point.
|
|
256
|
+
*/
|
|
257
|
+
var JobQueueProvider = class extends JobDispatcher {
|
|
258
|
+
kind = "queue";
|
|
259
|
+
alepha = $inject(Alepha);
|
|
260
|
+
jobProviderRef;
|
|
261
|
+
getJobProvider() {
|
|
262
|
+
if (!this.jobProviderRef) this.jobProviderRef = this.alepha.inject(JobProvider);
|
|
263
|
+
return this.jobProviderRef;
|
|
264
|
+
}
|
|
265
|
+
queue = $queue({
|
|
266
|
+
name: "api:jobs:dispatch",
|
|
267
|
+
schema: t.object({
|
|
268
|
+
jobName: t.text(),
|
|
269
|
+
executionId: t.text()
|
|
270
|
+
}),
|
|
271
|
+
handler: async (msg) => {
|
|
272
|
+
await this.getJobProvider().processExecution(msg.payload.jobName, msg.payload.executionId);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
async dispatch(jobName, executionId) {
|
|
276
|
+
await this.queue.push({
|
|
277
|
+
jobName,
|
|
278
|
+
executionId
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Fan-out to a single variadic `queue.push(...payloads)` call so the
|
|
283
|
+
* underlying queue provider can batch the network round-trips when it
|
|
284
|
+
* supports it (Cloudflare Queues, Redis pipelines).
|
|
285
|
+
*/
|
|
286
|
+
async dispatchMany(items) {
|
|
287
|
+
if (items.length === 0) return;
|
|
288
|
+
await this.queue.push(...items);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Backwards-compatible alias for {@link dispatch}. Older code paths called
|
|
292
|
+
* `JobQueueProvider.push(jobName, executionId)` directly; new code should
|
|
293
|
+
* go through the `JobDispatcher.dispatch` API.
|
|
294
|
+
*/
|
|
295
|
+
async push(jobName, executionId) {
|
|
296
|
+
return this.dispatch(jobName, executionId);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
//#endregion
|
|
152
300
|
//#region ../../src/api/jobs/providers/JobProvider.ts
|
|
153
301
|
const PRIORITY_MAP = {
|
|
154
302
|
critical: 0,
|
|
@@ -162,29 +310,50 @@ const PRIORITY_REVERSE = {
|
|
|
162
310
|
2: "normal",
|
|
163
311
|
3: "low"
|
|
164
312
|
};
|
|
165
|
-
const SWEEP_CRON = "*/5 * * * *";
|
|
166
313
|
/**
|
|
167
|
-
* Coordinates cron
|
|
168
|
-
*
|
|
314
|
+
* Coordinates cron and push jobs with a durable outbox table and a single
|
|
315
|
+
* reconciliation sweep. The actual delivery channel (queue / direct) is
|
|
316
|
+
* abstracted behind {@link JobDispatcher}, substituted by DI:
|
|
169
317
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
318
|
+
* - **DirectJobDispatcher** (default, registered by `AlephaApiJobs`) —
|
|
319
|
+
* runs the handler in-process right after `push()` returns.
|
|
320
|
+
* - **QueueJobDispatcher** (registered by `AlephaApiJobsQueue`) — sends
|
|
321
|
+
* the executionId through `AlephaQueue` so a pool of workers can pick
|
|
322
|
+
* it up.
|
|
173
323
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
324
|
+
* Push flow:
|
|
325
|
+
* push() → INSERT row (pending) → dispatcher.dispatch(jobName, id)
|
|
326
|
+
* worker → claim → UPDATE running → handler → DELETE/UPDATE on success
|
|
327
|
+
* → UPDATE error / scheduled (retry) on failure
|
|
176
328
|
*
|
|
177
|
-
*
|
|
329
|
+
* Cron flow:
|
|
330
|
+
* scheduler tick → acquire lock → executeInline (no retry)
|
|
331
|
+
* → enqueue + dispatch (retry declared)
|
|
332
|
+
*
|
|
333
|
+
* Sweep responsibilities (every `sweepCron`):
|
|
178
334
|
* - re-enqueue pending rows older than `staleThreshold`
|
|
179
|
-
* -
|
|
180
|
-
* - move `scheduled` rows with `scheduledAt <= now` to pending +
|
|
335
|
+
* - mark crashed running rows as failed and apply retry policy
|
|
336
|
+
* - move `scheduled` rows with `scheduledAt <= now` to pending + dispatch
|
|
181
337
|
* - trim per-job history beyond `keepLastSuccess` / `keepLastError`
|
|
182
338
|
*/
|
|
183
339
|
var JobProvider = class {
|
|
184
340
|
alepha = $inject(Alepha);
|
|
185
341
|
dt = $inject(DateTimeProvider);
|
|
186
342
|
cronProvider = $inject(CronProvider);
|
|
343
|
+
lockProvider = $inject(LockProvider);
|
|
187
344
|
config = $state(jobConfig);
|
|
345
|
+
/**
|
|
346
|
+
* Resolved at first use (after the container is fully wired) — picks
|
|
347
|
+
* the queue dispatcher when `AlephaApiJobsQueue` was loaded, otherwise
|
|
348
|
+
* the direct dispatcher. Lazy because both dispatchers inject
|
|
349
|
+
* `JobProvider` themselves; resolving them at field-init time would
|
|
350
|
+
* create a circular construction.
|
|
351
|
+
*/
|
|
352
|
+
dispatcherRef;
|
|
353
|
+
get dispatcher() {
|
|
354
|
+
if (!this.dispatcherRef) this.dispatcherRef = this.alepha.has(JobQueueProvider) ? this.alepha.inject(JobQueueProvider) : this.alepha.inject(DirectJobDispatcher);
|
|
355
|
+
return this.dispatcherRef;
|
|
356
|
+
}
|
|
188
357
|
log = $logger();
|
|
189
358
|
executions = $repository(jobExecutionEntity);
|
|
190
359
|
jobs = /* @__PURE__ */ new Map();
|
|
@@ -192,22 +361,17 @@ var JobProvider = class {
|
|
|
192
361
|
abortControllers = /* @__PURE__ */ new Map();
|
|
193
362
|
perExecutionLogs = /* @__PURE__ */ new Map();
|
|
194
363
|
stopping = false;
|
|
195
|
-
/**
|
|
196
|
-
* Set by `JobQueueProvider` when `AlephaApiJobsQueue` is loaded.
|
|
197
|
-
* When null, queue-mode jobs cannot be pushed.
|
|
198
|
-
*/
|
|
199
|
-
queueDispatch = null;
|
|
200
364
|
registerJob(name, options) {
|
|
201
365
|
if (this.jobs.has(name)) throw new AlephaError(`Job already registered: ${name}`);
|
|
202
366
|
if (options.cron && options.schema) throw new AlephaError(`Job '${name}' declares both 'cron' and 'schema'. A job must be either cron-only (recurring) or queue-only (push-based). Split into two jobs.`);
|
|
203
367
|
if (!options.cron && !options.schema) throw new AlephaError(`Job '${name}' must declare either 'cron' (for recurring tasks) or 'schema' (for queue-mode tasks).`);
|
|
204
|
-
const
|
|
368
|
+
const kind = options.cron ? "cron" : "queue";
|
|
205
369
|
this.jobs.set(name, {
|
|
206
370
|
name,
|
|
207
371
|
options,
|
|
208
|
-
|
|
372
|
+
kind
|
|
209
373
|
});
|
|
210
|
-
this.log.debug(`Registered ${
|
|
374
|
+
this.log.debug(`Registered ${kind} job '${name}'`, {
|
|
211
375
|
cron: options.cron,
|
|
212
376
|
priority: options.priority ?? "normal",
|
|
213
377
|
retries: options.retry?.retries ?? 0
|
|
@@ -223,24 +387,155 @@ var JobProvider = class {
|
|
|
223
387
|
getRegisteredJobs() {
|
|
224
388
|
return this.jobs;
|
|
225
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* Resolves what *actually* runs at dispatch time. Cron jobs are always
|
|
392
|
+
* "cron"; non-cron jobs delegate to the active `JobDispatcher` (queue
|
|
393
|
+
* vs. direct), which is determined by which modules the app loaded.
|
|
394
|
+
*/
|
|
395
|
+
effectiveMode(name) {
|
|
396
|
+
if (this.getRegistration(name).kind === "cron") return "cron";
|
|
397
|
+
return this.dispatcher.kind;
|
|
398
|
+
}
|
|
226
399
|
async runCron(name) {
|
|
227
400
|
const registration = this.getRegistration(name);
|
|
228
|
-
if (registration.
|
|
229
|
-
|
|
230
|
-
const executionId = crypto.randomUUID();
|
|
231
|
-
const promise = this.executeInline(registration, executionId, {
|
|
232
|
-
payload: void 0,
|
|
233
|
-
attempt: 1,
|
|
401
|
+
if (registration.kind !== "cron") throw new AlephaError(`Job '${name}' is not cron-mode`);
|
|
402
|
+
await this.runCronLocked(registration, {
|
|
234
403
|
triggeredBy: "system",
|
|
235
404
|
triggeredByName: "system (cron)"
|
|
236
405
|
});
|
|
237
|
-
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Cron-mode runner that respects the per-job distributed lock.
|
|
409
|
+
* Used by both the scheduled tick and manual `trigger()` calls so that an
|
|
410
|
+
* admin-triggered run on one instance can't race a scheduled run on another.
|
|
411
|
+
*
|
|
412
|
+
* **Two paths depending on `retry`:**
|
|
413
|
+
*
|
|
414
|
+
* - **No `retry`** — runs the handler inline. No DB row on success;
|
|
415
|
+
* error row only on failure. The "next tick" is the implicit retry.
|
|
416
|
+
* - **`retry` declared** — enqueues a synthetic execution row and hands
|
|
417
|
+
* it to the dispatcher. The handler then runs through the same path
|
|
418
|
+
* as a queue/direct push (claim, retry-on-fail, sweep recovery). Use
|
|
419
|
+
* this when a single failed tick must not block work for the whole
|
|
420
|
+
* `cron` interval (e.g. once-daily jobs).
|
|
421
|
+
*/
|
|
422
|
+
async runCronLocked(registration, ctx) {
|
|
423
|
+
if (this.stopping) return;
|
|
424
|
+
const useLock = registration.options.lock !== false;
|
|
425
|
+
if (useLock) {
|
|
426
|
+
if (!await this.acquireCronLock(registration)) {
|
|
427
|
+
this.log.debug(`Cron '${registration.name}' skipped — another instance holds the lock`);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
238
431
|
try {
|
|
239
|
-
|
|
432
|
+
if (registration.options.retry) {
|
|
433
|
+
await this.enqueueCronExecution(registration, ctx);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const executionId = crypto.randomUUID();
|
|
437
|
+
const promise = this.executeInline(registration, executionId, {
|
|
438
|
+
payload: void 0,
|
|
439
|
+
attempt: 1,
|
|
440
|
+
triggeredBy: ctx.triggeredBy,
|
|
441
|
+
triggeredByName: ctx.triggeredByName
|
|
442
|
+
});
|
|
443
|
+
this.inFlight.add(promise);
|
|
444
|
+
try {
|
|
445
|
+
await promise;
|
|
446
|
+
} finally {
|
|
447
|
+
this.inFlight.delete(promise);
|
|
448
|
+
}
|
|
240
449
|
} finally {
|
|
241
|
-
this.
|
|
450
|
+
if (useLock) await this.releaseCronLock(registration);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Materialize a cron tick into the outbox so it goes through the normal
|
|
455
|
+
* retry/sweep path. Used when the user opts into `retry` on a cron job —
|
|
456
|
+
* a transient failure no longer means "wait for the next cron tick", it
|
|
457
|
+
* means "the sweep will retry within `sweepCron`".
|
|
458
|
+
*/
|
|
459
|
+
async enqueueCronExecution(registration, ctx) {
|
|
460
|
+
const opts = registration.options;
|
|
461
|
+
const maxAttempts = (opts.retry?.retries ?? 0) + 1;
|
|
462
|
+
const execution = await this.executions.create({
|
|
463
|
+
jobName: registration.name,
|
|
464
|
+
payload: void 0,
|
|
465
|
+
status: "pending",
|
|
466
|
+
priority: PRIORITY_MAP[opts.priority ?? "normal"],
|
|
467
|
+
maxAttempts,
|
|
468
|
+
triggeredBy: ctx.triggeredBy,
|
|
469
|
+
triggeredByName: ctx.triggeredByName
|
|
470
|
+
});
|
|
471
|
+
await this.dispatch(registration.name, execution.id);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Acquire a per-job NX lock keyed by `cron-job:<name>` so that a single
|
|
475
|
+
* tick across all replicas runs exactly one execution. Auto-expires after
|
|
476
|
+
* `2 * timeout` (or 5 minutes if no per-job timeout) so a crashed worker
|
|
477
|
+
* cannot permanently block the cron from firing.
|
|
478
|
+
*
|
|
479
|
+
* **Caveat — same-process double-fire is not prevented.** The lock value
|
|
480
|
+
* is a per-process holder id, so two concurrent ticks on the same process
|
|
481
|
+
* (e.g. a scheduled tick overlapping an admin `trigger()` call) will both
|
|
482
|
+
* see "we own it". This is acceptable for the multi-replica use case the
|
|
483
|
+
* lock targets; a process that overlaps its own cron handler should set a
|
|
484
|
+
* smaller `timeout` or use idempotent handler logic. A future fix can add
|
|
485
|
+
* a per-process Set guard before reaching the LockProvider.
|
|
486
|
+
*/
|
|
487
|
+
async acquireCronLock(registration) {
|
|
488
|
+
const lockKey = this.cronLockKey(registration.name);
|
|
489
|
+
const ttlMs = registration.options.timeout ? this.dt.duration(registration.options.timeout).as("milliseconds") * 2 : 300 * 1e3;
|
|
490
|
+
const value = `${this.lockHolderId},${this.dt.nowISOString()}`;
|
|
491
|
+
try {
|
|
492
|
+
const [holderId] = (await this.lockProvider.set(lockKey, value, true, ttlMs)).split(",");
|
|
493
|
+
return holderId === this.lockHolderId;
|
|
494
|
+
} catch (e) {
|
|
495
|
+
this.log.warn(`Cron lock acquire failed for '${registration.name}'`, e);
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Update only when the row is still in one of the expected statuses.
|
|
501
|
+
* Logs and returns silently when the guard rejects — this happens when a
|
|
502
|
+
* concurrent operation (most often `cancel()`) has already moved the row
|
|
503
|
+
* into a terminal state. We must not overwrite that.
|
|
504
|
+
*/
|
|
505
|
+
async guardedUpdate(executionId, expectedStatuses, patch, label) {
|
|
506
|
+
try {
|
|
507
|
+
await this.executions.updateOne({
|
|
508
|
+
id: { eq: executionId },
|
|
509
|
+
status: { inArray: expectedStatuses }
|
|
510
|
+
}, patch);
|
|
511
|
+
} catch (e) {
|
|
512
|
+
if (e instanceof DbEntityNotFoundError) {
|
|
513
|
+
this.log.debug(`${label}: row ${executionId} not in expected status — skipping write`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
throw e;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async releaseCronLock(registration) {
|
|
520
|
+
try {
|
|
521
|
+
await this.lockProvider.del(this.cronLockKey(registration.name));
|
|
522
|
+
} catch (e) {
|
|
523
|
+
this.log.debug(`Cron lock release failed for '${registration.name}' (will expire by TTL)`, e);
|
|
242
524
|
}
|
|
243
525
|
}
|
|
526
|
+
cronLockKey(jobName) {
|
|
527
|
+
return `alepha.api.jobs.cron:${jobName}`;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Stable per-process id used as the lock value — survives multiple ticks.
|
|
531
|
+
* Lazy so that Cloudflare Workers (which forbid random in global scope)
|
|
532
|
+
* stay happy.
|
|
533
|
+
*/
|
|
534
|
+
lockHolderIdValue;
|
|
535
|
+
get lockHolderId() {
|
|
536
|
+
if (!this.lockHolderIdValue) this.lockHolderIdValue = crypto.randomUUID();
|
|
537
|
+
return this.lockHolderIdValue;
|
|
538
|
+
}
|
|
244
539
|
/**
|
|
245
540
|
* Execute a cron handler inline. Records a row only on error (or always,
|
|
246
541
|
* when `record: 'all'`). No DB writes on the happy path by default.
|
|
@@ -339,7 +634,7 @@ var JobProvider = class {
|
|
|
339
634
|
}
|
|
340
635
|
async push(name, payload, options) {
|
|
341
636
|
const registration = this.getRegistration(name);
|
|
342
|
-
if (registration.
|
|
637
|
+
if (registration.kind !== "queue") throw new AlephaError(`Job '${name}' is not queue-mode (no schema declared). Use trigger() instead.`);
|
|
343
638
|
const opts = registration.options;
|
|
344
639
|
const validated = this.alepha.codec.validate(opts.schema, payload);
|
|
345
640
|
const priority = PRIORITY_MAP[options?.priority ?? opts.priority ?? "normal"];
|
|
@@ -368,7 +663,7 @@ var JobProvider = class {
|
|
|
368
663
|
triggeredBy: options.triggeredBy,
|
|
369
664
|
triggeredByName: options.triggeredByName
|
|
370
665
|
});
|
|
371
|
-
if (status === "pending") await this.
|
|
666
|
+
if (status === "pending") await this.dispatch(name, execution.id);
|
|
372
667
|
else if (status === "scheduled" && scheduledAt) this.scheduleOptimisticDispatch(name, execution.id, scheduledAt);
|
|
373
668
|
return execution.id;
|
|
374
669
|
}
|
|
@@ -382,7 +677,7 @@ var JobProvider = class {
|
|
|
382
677
|
triggeredBy: options?.triggeredBy,
|
|
383
678
|
triggeredByName: options?.triggeredByName
|
|
384
679
|
});
|
|
385
|
-
if (status === "pending") await this.
|
|
680
|
+
if (status === "pending") await this.dispatch(name, execution.id);
|
|
386
681
|
else if (status === "scheduled" && scheduledAt) this.scheduleOptimisticDispatch(name, execution.id, scheduledAt);
|
|
387
682
|
return execution.id;
|
|
388
683
|
}
|
|
@@ -401,7 +696,7 @@ var JobProvider = class {
|
|
|
401
696
|
async pushMany(name, items) {
|
|
402
697
|
if (items.length === 0) return [];
|
|
403
698
|
const registration = this.getRegistration(name);
|
|
404
|
-
if (registration.
|
|
699
|
+
if (registration.kind !== "queue") throw new AlephaError(`Job '${name}' is not queue-mode (no schema declared).`);
|
|
405
700
|
const opts = registration.options;
|
|
406
701
|
const maxAttempts = (opts.retry?.retries ?? 0) + 1;
|
|
407
702
|
const keyed = [];
|
|
@@ -440,11 +735,16 @@ var JobProvider = class {
|
|
|
440
735
|
}
|
|
441
736
|
if (bulk.length > 0) {
|
|
442
737
|
const created = await this.executions.createMany(bulk);
|
|
738
|
+
const toDispatch = [];
|
|
443
739
|
for (const exec of created) {
|
|
444
740
|
ids.push(exec.id);
|
|
445
|
-
if (exec.status === "pending" && !this.stopping)
|
|
741
|
+
if (exec.status === "pending" && !this.stopping) toDispatch.push({
|
|
742
|
+
jobName: name,
|
|
743
|
+
executionId: exec.id
|
|
744
|
+
});
|
|
446
745
|
else if (exec.status === "scheduled" && exec.scheduledAt && !this.stopping) this.scheduleOptimisticDispatch(name, exec.id, exec.scheduledAt);
|
|
447
746
|
}
|
|
747
|
+
if (toDispatch.length > 0) await this.dispatchMany(toDispatch);
|
|
448
748
|
}
|
|
449
749
|
this.log.debug(`pushMany '${name}': ${ids.length} jobs created`, {
|
|
450
750
|
bulk: bulk.length,
|
|
@@ -452,18 +752,27 @@ var JobProvider = class {
|
|
|
452
752
|
});
|
|
453
753
|
return ids;
|
|
454
754
|
}
|
|
455
|
-
|
|
755
|
+
/**
|
|
756
|
+
* Hand a single execution to the active `JobDispatcher`. Whether that
|
|
757
|
+
* results in a queue send or in-process execution depends on which
|
|
758
|
+
* dispatcher is wired (see {@link JobDispatcher}).
|
|
759
|
+
*/
|
|
760
|
+
async dispatch(jobName, executionId) {
|
|
456
761
|
if (this.stopping) return;
|
|
457
|
-
|
|
458
|
-
|
|
762
|
+
await this.dispatcher.dispatch(jobName, executionId);
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Batched variant. Used by `pushMany` so a backing queue can do a single
|
|
766
|
+
* batch network call (e.g. Cloudflare Queues `sendBatch`).
|
|
767
|
+
*/
|
|
768
|
+
async dispatchMany(items) {
|
|
769
|
+
if (this.stopping || items.length === 0) return;
|
|
770
|
+
await this.dispatcher.dispatchMany(items);
|
|
459
771
|
}
|
|
460
772
|
async trigger(name, context) {
|
|
461
773
|
const registration = this.getRegistration(name);
|
|
462
|
-
if (registration.
|
|
463
|
-
|
|
464
|
-
await this.executeInline(registration, executionId, {
|
|
465
|
-
payload: void 0,
|
|
466
|
-
attempt: 1,
|
|
774
|
+
if (registration.kind === "cron") {
|
|
775
|
+
await this.runCronLocked(registration, {
|
|
467
776
|
triggeredBy: context?.triggeredBy,
|
|
468
777
|
triggeredByName: context?.triggeredByName
|
|
469
778
|
});
|
|
@@ -499,8 +808,8 @@ var JobProvider = class {
|
|
|
499
808
|
this.log.warn(`Unknown job '${jobName}' — skipping execution`, { executionId });
|
|
500
809
|
return;
|
|
501
810
|
}
|
|
502
|
-
if (registration.
|
|
503
|
-
this.log.warn(`Job '${jobName}'
|
|
811
|
+
if (registration.kind !== "queue" && !registration.options.retry) {
|
|
812
|
+
this.log.warn(`Job '${jobName}' has no outbox path (no schema and no retry) — skipping`, { executionId });
|
|
504
813
|
return;
|
|
505
814
|
}
|
|
506
815
|
const promise = this.processQueueExecution(registration, executionId);
|
|
@@ -515,12 +824,11 @@ var JobProvider = class {
|
|
|
515
824
|
const jobName = registration.name;
|
|
516
825
|
const opts = registration.options;
|
|
517
826
|
const record = opts.record ?? "error";
|
|
518
|
-
|
|
827
|
+
const execution = await this.claim(executionId);
|
|
828
|
+
if (!execution) {
|
|
519
829
|
this.log.debug(`Execution ${executionId} already claimed, skipping`);
|
|
520
830
|
return;
|
|
521
831
|
}
|
|
522
|
-
const execution = await this.executions.findById(executionId);
|
|
523
|
-
if (!execution) return;
|
|
524
832
|
const contextId = this.alepha.context.createContextId();
|
|
525
833
|
this.perExecutionLogs.set(contextId, []);
|
|
526
834
|
const abortController = new AbortController();
|
|
@@ -581,56 +889,58 @@ var JobProvider = class {
|
|
|
581
889
|
this.perExecutionLogs.delete(contextId);
|
|
582
890
|
}
|
|
583
891
|
}
|
|
892
|
+
/**
|
|
893
|
+
* Transition pending → running and return the post-update row.
|
|
894
|
+
* Two round-trips: read current attempt, then guarded UPDATE … RETURNING.
|
|
895
|
+
* Returns null when the row is gone or already claimed by another worker.
|
|
896
|
+
* The returned row replaces a separate post-claim findById, so the dispatch
|
|
897
|
+
* path is 2 queries instead of 3.
|
|
898
|
+
*/
|
|
584
899
|
async claim(executionId) {
|
|
585
|
-
const
|
|
586
|
-
if (!
|
|
900
|
+
const current = await this.executions.findById(executionId);
|
|
901
|
+
if (!current) return null;
|
|
587
902
|
try {
|
|
588
|
-
await this.executions.updateOne({
|
|
903
|
+
return await this.executions.updateOne({
|
|
589
904
|
id: { eq: executionId },
|
|
590
905
|
status: { eq: "pending" }
|
|
591
906
|
}, {
|
|
592
907
|
status: "running",
|
|
593
|
-
attempt:
|
|
908
|
+
attempt: current.attempt + 1,
|
|
594
909
|
startedAt: this.dt.nowISOString()
|
|
595
910
|
});
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
911
|
+
} catch (e) {
|
|
912
|
+
if (e instanceof DbEntityNotFoundError) return null;
|
|
913
|
+
throw e;
|
|
599
914
|
}
|
|
600
915
|
}
|
|
601
916
|
async handleFailure(executionId, registration, currentAttempt, error, contextId) {
|
|
602
917
|
const jobName = registration.name;
|
|
603
918
|
const retry = registration.options.retry;
|
|
604
919
|
const maxAttempts = (retry?.retries ?? 0) + 1;
|
|
605
|
-
if (retry && currentAttempt
|
|
606
|
-
const nextScheduledAt = this.
|
|
607
|
-
this.log.info(`Job '${jobName}' failed, scheduling retry ${currentAttempt + 1}/${maxAttempts}`, {
|
|
920
|
+
if (retry && currentAttempt < maxAttempts && (retry.when ? retry.when(error) : true)) {
|
|
921
|
+
const nextScheduledAt = this.dt.nowISOString();
|
|
922
|
+
this.log.info(`Job '${jobName}' failed, scheduling retry ${currentAttempt + 1}/${maxAttempts} (sweep will pick up)`, {
|
|
608
923
|
executionId,
|
|
609
|
-
error: error.message
|
|
610
|
-
nextScheduledAt
|
|
924
|
+
error: error.message
|
|
611
925
|
});
|
|
612
|
-
await this.
|
|
926
|
+
await this.guardedUpdate(executionId, ["running"], {
|
|
613
927
|
status: "scheduled",
|
|
614
928
|
error: error.message,
|
|
615
929
|
scheduledAt: nextScheduledAt,
|
|
616
930
|
logs: this.snapshotLogs(contextId)
|
|
617
|
-
});
|
|
618
|
-
const delayMs = Math.max(0, new Date(nextScheduledAt).getTime() - this.dt.nowMillis());
|
|
619
|
-
this.dt.createTimeout(() => {
|
|
620
|
-
this.dispatchScheduled(jobName, executionId);
|
|
621
|
-
}, delayMs);
|
|
931
|
+
}, "retry-after-failure");
|
|
622
932
|
} else {
|
|
623
933
|
this.log.info(`Job '${jobName}' dead after ${currentAttempt} attempt(s)`, {
|
|
624
934
|
executionId,
|
|
625
935
|
error: error.message
|
|
626
936
|
});
|
|
627
|
-
await this.
|
|
937
|
+
await this.guardedUpdate(executionId, ["running"], {
|
|
628
938
|
status: "error",
|
|
629
939
|
error: error.message,
|
|
630
940
|
completedAt: this.dt.nowISOString(),
|
|
631
941
|
key: null,
|
|
632
942
|
logs: this.snapshotLogs(contextId)
|
|
633
|
-
});
|
|
943
|
+
}, "terminal-failure");
|
|
634
944
|
}
|
|
635
945
|
await this.alepha.events.emit("job:error", {
|
|
636
946
|
name: jobName,
|
|
@@ -638,16 +948,6 @@ var JobProvider = class {
|
|
|
638
948
|
executionId
|
|
639
949
|
}, { catch: true });
|
|
640
950
|
}
|
|
641
|
-
computeBackoff(retry, attempt) {
|
|
642
|
-
const now = this.dt.now();
|
|
643
|
-
if (!retry.backoff) return now.add(1, "second").toISOString();
|
|
644
|
-
if (Array.isArray(retry.backoff)) return now.add(this.dt.duration(retry.backoff)).toISOString();
|
|
645
|
-
const backoff = retry.backoff;
|
|
646
|
-
let delayMs = this.dt.duration(backoff.initial).as("milliseconds") * (backoff.factor ?? 2) ** (attempt - 1);
|
|
647
|
-
if (backoff.max) delayMs = Math.min(delayMs, this.dt.duration(backoff.max).as("milliseconds"));
|
|
648
|
-
if (backoff.jitter) delayMs = delayMs * (.75 + Math.random() * .5);
|
|
649
|
-
return now.add(delayMs, "millisecond").toISOString();
|
|
650
|
-
}
|
|
651
951
|
snapshotLogs(contextId) {
|
|
652
952
|
const entries = this.perExecutionLogs.get(contextId);
|
|
653
953
|
if (!entries || entries.length === 0) return void 0;
|
|
@@ -683,7 +983,7 @@ var JobProvider = class {
|
|
|
683
983
|
for (const exec of due) {
|
|
684
984
|
if (!this.jobs.has(exec.jobName)) continue;
|
|
685
985
|
await this.executions.updateById(exec.id, { status: "pending" });
|
|
686
|
-
await this.
|
|
986
|
+
await this.dispatchSafe(exec.jobName, exec.id);
|
|
687
987
|
}
|
|
688
988
|
const staleIso = now.subtract(this.config.staleThreshold, "millisecond").toISOString();
|
|
689
989
|
const staleWhere = this.executions.createQueryWhere();
|
|
@@ -698,7 +998,7 @@ var JobProvider = class {
|
|
|
698
998
|
});
|
|
699
999
|
for (const exec of stale) {
|
|
700
1000
|
if (!this.jobs.has(exec.jobName)) continue;
|
|
701
|
-
await this.
|
|
1001
|
+
await this.dispatchSafe(exec.jobName, exec.id);
|
|
702
1002
|
}
|
|
703
1003
|
const runningWhere = this.executions.createQueryWhere();
|
|
704
1004
|
runningWhere.status = { eq: "running" };
|
|
@@ -721,9 +1021,9 @@ var JobProvider = class {
|
|
|
721
1021
|
this.log.error("Sweep failed", { error: e });
|
|
722
1022
|
}
|
|
723
1023
|
}
|
|
724
|
-
async
|
|
1024
|
+
async dispatchSafe(jobName, executionId) {
|
|
725
1025
|
try {
|
|
726
|
-
await this.
|
|
1026
|
+
await this.dispatch(jobName, executionId);
|
|
727
1027
|
} catch (e) {
|
|
728
1028
|
this.log.warn(`Sweep failed to dispatch ${jobName} (${executionId})`, e);
|
|
729
1029
|
}
|
|
@@ -740,7 +1040,7 @@ var JobProvider = class {
|
|
|
740
1040
|
id: { eq: executionId },
|
|
741
1041
|
status: { eq: "scheduled" }
|
|
742
1042
|
}, { status: "pending" });
|
|
743
|
-
await this.
|
|
1043
|
+
await this.dispatchSafe(jobName, executionId);
|
|
744
1044
|
} catch {}
|
|
745
1045
|
}
|
|
746
1046
|
async trimRingBuffers() {
|
|
@@ -777,10 +1077,21 @@ var JobProvider = class {
|
|
|
777
1077
|
onStart = $hook({
|
|
778
1078
|
on: "start",
|
|
779
1079
|
handler: async () => {
|
|
780
|
-
|
|
1080
|
+
const modes = {
|
|
1081
|
+
cron: 0,
|
|
1082
|
+
queue: 0,
|
|
1083
|
+
direct: 0
|
|
1084
|
+
};
|
|
1085
|
+
const perJob = {};
|
|
1086
|
+
for (const [name] of this.jobs) {
|
|
1087
|
+
const m = this.effectiveMode(name);
|
|
1088
|
+
modes[m]++;
|
|
1089
|
+
perJob[name] = m;
|
|
1090
|
+
}
|
|
781
1091
|
this.log.info(`Job system OK`, {
|
|
782
|
-
|
|
783
|
-
jobs: this.jobs.size
|
|
1092
|
+
modes,
|
|
1093
|
+
jobs: this.jobs.size,
|
|
1094
|
+
perJob
|
|
784
1095
|
});
|
|
785
1096
|
this.alepha.events.on("log", ({ entry }) => {
|
|
786
1097
|
const ctx = entry.context;
|
|
@@ -790,7 +1101,7 @@ var JobProvider = class {
|
|
|
790
1101
|
entries.push(entry);
|
|
791
1102
|
});
|
|
792
1103
|
if (!this.alepha.isServerless()) await this.sweep();
|
|
793
|
-
this.cronProvider.createCronJob("api:jobs:sweep",
|
|
1104
|
+
this.cronProvider.createCronJob("api:jobs:sweep", this.config.sweepCron, async () => {
|
|
794
1105
|
await this.sweep();
|
|
795
1106
|
}, true);
|
|
796
1107
|
}
|
|
@@ -889,6 +1200,19 @@ var JobService = class {
|
|
|
889
1200
|
};
|
|
890
1201
|
}
|
|
891
1202
|
/**
|
|
1203
|
+
* Convert the int-priority storage column into the public enum string.
|
|
1204
|
+
* The cast through `unknown` skips TypeScript's structural check between
|
|
1205
|
+
* the entity-level row (`priority: number`) and the resource schema
|
|
1206
|
+
* (`priority: enum`); the runtime values are correct.
|
|
1207
|
+
*/
|
|
1208
|
+
toResource(row) {
|
|
1209
|
+
return {
|
|
1210
|
+
...row,
|
|
1211
|
+
priority: PRIORITY_REVERSE[row.priority] ?? "normal",
|
|
1212
|
+
can: this.computeCan(row.status)
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
892
1216
|
* List every registered job with recent ok/error counts and lastRun.
|
|
893
1217
|
* One aggregate query covers all jobs.
|
|
894
1218
|
*/
|
|
@@ -936,14 +1260,11 @@ var JobService = class {
|
|
|
936
1260
|
result.push({
|
|
937
1261
|
name,
|
|
938
1262
|
description: opts.description,
|
|
939
|
-
type:
|
|
1263
|
+
type: this.jobProvider.effectiveMode(name),
|
|
940
1264
|
cron: opts.cron,
|
|
941
1265
|
priority: opts.priority ?? "normal",
|
|
942
1266
|
timeout: opts.timeout ? String(opts.timeout) : void 0,
|
|
943
|
-
retry: opts.retry ? {
|
|
944
|
-
retries: opts.retry.retries,
|
|
945
|
-
hasBackoff: Boolean(opts.retry.backoff)
|
|
946
|
-
} : void 0,
|
|
1267
|
+
retry: opts.retry ? { retries: opts.retry.retries } : void 0,
|
|
947
1268
|
recent: counts
|
|
948
1269
|
});
|
|
949
1270
|
}
|
|
@@ -964,10 +1285,7 @@ var JobService = class {
|
|
|
964
1285
|
direction: "desc"
|
|
965
1286
|
},
|
|
966
1287
|
limit: query.limit ?? 20
|
|
967
|
-
})).map((row) => (
|
|
968
|
-
...row,
|
|
969
|
-
can: this.computeCan(row.status)
|
|
970
|
-
}));
|
|
1288
|
+
})).map((row) => this.toResource(row));
|
|
971
1289
|
}
|
|
972
1290
|
/**
|
|
973
1291
|
* Full execution detail (includes captured logs).
|
|
@@ -975,10 +1293,7 @@ var JobService = class {
|
|
|
975
1293
|
async getExecution(id) {
|
|
976
1294
|
const execution = await this.executions.findById(id);
|
|
977
1295
|
if (!execution) throw new NotFoundError(`Execution not found: ${id}`);
|
|
978
|
-
return
|
|
979
|
-
...execution,
|
|
980
|
-
can: this.computeCan(execution.status)
|
|
981
|
-
};
|
|
1296
|
+
return this.toResource(execution);
|
|
982
1297
|
}
|
|
983
1298
|
/**
|
|
984
1299
|
* Manual trigger (cron jobs) or push-with-payload (queue jobs).
|
|
@@ -1103,69 +1418,69 @@ var AdminJobController = class {
|
|
|
1103
1418
|
});
|
|
1104
1419
|
};
|
|
1105
1420
|
//#endregion
|
|
1106
|
-
//#region ../../src/api/jobs/providers/JobQueueProvider.ts
|
|
1107
|
-
/**
|
|
1108
|
-
* Plumbs outbox-style dispatch through `AlephaQueue`.
|
|
1109
|
-
*
|
|
1110
|
-
* Registered only when the app imports `AlephaApiJobsQueue`. Sets
|
|
1111
|
-
* `JobProvider.queueDispatch` eagerly at instantiation so queue-mode jobs
|
|
1112
|
-
* can dispatch regardless of start-hook ordering.
|
|
1113
|
-
*/
|
|
1114
|
-
var JobQueueProvider = class {
|
|
1115
|
-
jobProvider = $inject(JobProvider);
|
|
1116
|
-
queue = $queue({
|
|
1117
|
-
name: "api:jobs:dispatch",
|
|
1118
|
-
schema: t.object({
|
|
1119
|
-
jobName: t.text(),
|
|
1120
|
-
executionId: t.text()
|
|
1121
|
-
}),
|
|
1122
|
-
handler: async (msg) => {
|
|
1123
|
-
await this.jobProvider.processExecution(msg.payload.jobName, msg.payload.executionId);
|
|
1124
|
-
}
|
|
1125
|
-
});
|
|
1126
|
-
constructor() {
|
|
1127
|
-
this.wireDispatcher();
|
|
1128
|
-
}
|
|
1129
|
-
wireDispatcher() {
|
|
1130
|
-
this.jobProvider.queueDispatch = (jobName, executionId) => this.push(jobName, executionId);
|
|
1131
|
-
}
|
|
1132
|
-
async push(jobName, executionId) {
|
|
1133
|
-
await this.queue.push({
|
|
1134
|
-
jobName,
|
|
1135
|
-
executionId
|
|
1136
|
-
});
|
|
1137
|
-
}
|
|
1138
|
-
};
|
|
1139
|
-
//#endregion
|
|
1140
1421
|
//#region ../../src/api/jobs/index.ts
|
|
1141
1422
|
/**
|
|
1142
1423
|
* Job execution framework — cron and durable queue work with a single primitive.
|
|
1143
1424
|
*
|
|
1144
|
-
* A `$job` is either **cron-only** (declares `cron`) or **
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1425
|
+
* A `$job` is either **cron-only** (declares `cron`) or **payload-only** (declares `schema`).
|
|
1426
|
+
*
|
|
1427
|
+
* **Three runtime modes:**
|
|
1428
|
+
*
|
|
1429
|
+
* - **cron** — fires on a schedule. Cron-mode jobs are protected by a
|
|
1430
|
+
* distributed lock by default (`lock: true`), so multi-replica Docker
|
|
1431
|
+
* deployments only run the handler once per tick. Override with
|
|
1432
|
+
* `lock: false` if you genuinely want every replica to fire.
|
|
1433
|
+
* - **queue** — push-driven, dispatched through the queue infrastructure
|
|
1434
|
+
* (`AlephaQueue`, e.g. Cloudflare Queues, Redis). Real-time delivery,
|
|
1435
|
+
* ideal for high-volume systems. Requires `AlephaApiJobsQueue`.
|
|
1436
|
+
* - **direct** — push-driven, processed in-process right after the caller
|
|
1437
|
+
* awaits the push. The DB outbox row is the durability guarantee — if
|
|
1438
|
+
* the process dies, the reconciliation sweep re-dispatches. Default
|
|
1439
|
+
* when `AlephaApiJobsQueue` is *not* loaded. Best for cheap deployments
|
|
1440
|
+
* (Cloudflare Workers, single-instance Node) where standing up a queue
|
|
1441
|
+
* is overkill.
|
|
1442
|
+
*
|
|
1443
|
+
* **Retries** are sweep-driven across all modes (no exponential backoff).
|
|
1444
|
+
* Granularity is bounded by `sweepCron` (default 5 min). The first retry
|
|
1445
|
+
* may land anywhere from a few seconds to ~5 min later depending on when
|
|
1446
|
+
* the next sweep tick fires. Cron jobs that declare `retry` go through
|
|
1447
|
+
* the same sweep path — a transient failure no longer means waiting for
|
|
1448
|
+
* the next cron tick (useful for once-daily jobs).
|
|
1449
|
+
*
|
|
1450
|
+
* **Runtime support for cron triggers**
|
|
1147
1451
|
*
|
|
1148
|
-
* **
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
1452
|
+
* - **Long-running Node / Docker** — `CronProvider` runs an in-process
|
|
1453
|
+
* timer loop. Multi-replica deployments serialize ticks via the cron
|
|
1454
|
+
* lock (see `$job.lock`).
|
|
1455
|
+
* - **Cloudflare Workers** — the build emits cron expressions into
|
|
1456
|
+
* `wrangler.jsonc`; Cloudflare invokes the worker on schedule and the
|
|
1457
|
+
* `cloudflare:scheduled` hook routes the event to the matching jobs.
|
|
1458
|
+
* - **Vercel** — the build emits cron entries into
|
|
1459
|
+
* `.vercel/output/config.json` mapped to `/_alepha/cron/:name`; the
|
|
1460
|
+
* serverless handler emits `serverless:cron` and `CronProvider` runs
|
|
1461
|
+
* the matching job. Set `CRON_SECRET` to require authenticated calls.
|
|
1152
1462
|
*
|
|
1153
1463
|
* @module alepha.api.jobs
|
|
1154
1464
|
*/
|
|
1155
1465
|
const AlephaApiJobs = $module({
|
|
1156
1466
|
name: "alepha.api.jobs",
|
|
1467
|
+
primitives: [$job],
|
|
1157
1468
|
imports: [AlephaScheduler, AlephaLock],
|
|
1158
1469
|
services: [
|
|
1159
1470
|
JobProvider,
|
|
1160
1471
|
JobService,
|
|
1161
|
-
AdminJobController
|
|
1472
|
+
AdminJobController,
|
|
1473
|
+
DirectJobDispatcher
|
|
1162
1474
|
]
|
|
1163
1475
|
});
|
|
1164
1476
|
/**
|
|
1165
1477
|
* Queue support for `$job`. Import alongside {@link AlephaApiJobs} when your
|
|
1166
|
-
* app declares queue-mode jobs (any `$job` with a `schema`)
|
|
1478
|
+
* app declares queue-mode jobs (any `$job` with a `schema`) and you want a
|
|
1479
|
+
* real queue (e.g. Cloudflare Queues, Redis) instead of in-process direct
|
|
1480
|
+
* execution.
|
|
1167
1481
|
*
|
|
1168
|
-
* Adds `JobQueueProvider`
|
|
1482
|
+
* Adds `JobQueueProvider` to the container. `JobProvider` detects its
|
|
1483
|
+
* presence at start-up and routes dispatches through it.
|
|
1169
1484
|
*
|
|
1170
1485
|
* @module alepha.api.jobs.queue
|
|
1171
1486
|
*/
|
|
@@ -1175,6 +1490,6 @@ const AlephaApiJobsQueue = $module({
|
|
|
1175
1490
|
services: [JobQueueProvider]
|
|
1176
1491
|
});
|
|
1177
1492
|
//#endregion
|
|
1178
|
-
export { $job, AdminJobController, AlephaApiJobs, AlephaApiJobsQueue, JobPrimitive, JobProvider, JobQueueProvider, JobService, PRIORITY_MAP, PRIORITY_REVERSE, jobConfig, jobExecutionEntity, jobExecutionQuerySchema, jobExecutionResourceSchema, jobRegistrationSchema, triggerJobSchema };
|
|
1493
|
+
export { $job, AdminJobController, AlephaApiJobs, AlephaApiJobsQueue, DirectJobDispatcher, JobDispatcher, JobPrimitive, JobProvider, JobQueueProvider, JobService, PRIORITY_MAP, PRIORITY_REVERSE, jobConfig, jobExecutionEntity, jobExecutionQuerySchema, jobExecutionResourceSchema, jobRegistrationSchema, triggerJobSchema };
|
|
1179
1494
|
|
|
1180
1495
|
//# sourceMappingURL=index.js.map
|