alepha 0.20.1 → 0.20.2
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/files/index.js +2 -1
- package/dist/api/files/index.js.map +1 -1
- package/dist/api/jobs/index.browser.js +64 -148
- package/dist/api/jobs/index.browser.js.map +1 -1
- package/dist/api/jobs/index.d.ts +371 -573
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +605 -1012
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/notifications/index.d.ts +78 -17
- package/dist/api/notifications/index.d.ts.map +1 -1
- package/dist/api/notifications/index.js +90 -23
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/payments/index.d.ts +2 -1
- package/dist/api/payments/index.d.ts.map +1 -1
- package/dist/api/payments/index.js +4 -2
- package/dist/api/payments/index.js.map +1 -1
- package/dist/api/users/index.d.ts +34 -31
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +13 -7
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.js +2 -1
- package/dist/api/verifications/index.js.map +1 -1
- package/dist/cli/core/index.d.ts +8 -34
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +43 -232
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/platform/index.d.ts +36 -11
- package/dist/cli/platform/index.d.ts.map +1 -1
- package/dist/cli/platform/index.js +93 -27
- package/dist/cli/platform/index.js.map +1 -1
- package/dist/command/index.d.ts +1 -1
- package/dist/core/index.browser.js +6 -0
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +6 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js +6 -0
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js +6 -0
- package/dist/core/index.workerd.js.map +1 -1
- package/dist/react/form/index.d.ts +60 -1
- package/dist/react/form/index.d.ts.map +1 -1
- package/dist/react/form/index.js +86 -1
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/head/index.browser.js +16 -1
- package/dist/react/head/index.browser.js.map +1 -1
- package/dist/react/head/index.d.ts +6 -0
- package/dist/react/head/index.d.ts.map +1 -1
- package/dist/react/head/index.js +16 -1
- package/dist/react/head/index.js.map +1 -1
- package/dist/react/router/index.browser.js +0 -10
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +35 -12
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +0 -10
- package/dist/react/router/index.js.map +1 -1
- package/dist/react/ui/index.d.ts +124 -0
- package/dist/react/ui/index.d.ts.map +1 -0
- package/dist/react/ui/index.js +206 -0
- package/dist/react/ui/index.js.map +1 -0
- package/dist/router/index.d.ts +13 -13
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +45 -32
- package/dist/router/index.js.map +1 -1
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +1 -0
- package/dist/system/index.js.map +1 -1
- package/dist/topic/core/index.js +1 -1
- package/dist/topic/core/index.js.map +1 -1
- package/package.json +6 -23
- package/src/api/files/jobs/FileJobs.ts +2 -1
- package/src/api/jobs/__tests__/$job.spec.ts +316 -2867
- package/src/api/jobs/controllers/AdminJobController.ts +29 -138
- package/src/api/jobs/entities/jobExecutionEntity.ts +27 -19
- package/src/api/jobs/index.browser.ts +5 -7
- package/src/api/jobs/index.ts +23 -51
- package/src/api/jobs/primitives/$job.ts +66 -58
- package/src/api/jobs/providers/JobProvider.ts +561 -566
- package/src/api/jobs/providers/JobQueueProvider.ts +18 -19
- package/src/api/jobs/schemas/jobConfigAtom.ts +20 -23
- package/src/api/jobs/schemas/jobExecutionQuerySchema.ts +3 -27
- package/src/api/jobs/schemas/jobExecutionResourceSchema.ts +5 -7
- package/src/api/jobs/schemas/jobRegistrationSchema.ts +7 -4
- package/src/api/jobs/schemas/triggerJobSchema.ts +0 -1
- package/src/api/jobs/services/JobService.ts +90 -483
- package/src/api/notifications/controllers/AdminNotificationController.ts +19 -12
- package/src/api/notifications/index.ts +7 -4
- package/src/api/notifications/jobs/NotificationJobs.ts +83 -12
- package/src/api/payments/services/PaymentService.ts +4 -2
- package/src/api/users/__tests__/UserJobs.spec.ts +10 -49
- package/src/api/users/audits/UserAudits.ts +3 -1
- package/src/api/users/buckets/UserBuckets.ts +2 -1
- package/src/api/users/index.ts +1 -4
- package/src/api/users/jobs/UserJobs.ts +5 -4
- package/src/api/verifications/jobs/VerificationJobs.ts +2 -1
- package/src/cli/core/__tests__/init.spec.ts +1 -1
- package/src/cli/core/commands/init.ts +0 -12
- package/src/cli/core/services/PackageManagerUtils.ts +2 -9
- package/src/cli/core/services/ProjectScaffolder.ts +17 -65
- package/src/cli/core/templates/agentMd.ts +2 -8
- package/src/cli/core/templates/apiIndexTs.ts +4 -18
- package/src/cli/core/templates/mainCss.ts +1 -36
- package/src/cli/core/templates/vitestConfigTs.ts +17 -0
- package/src/cli/core/templates/webAppRouterTs.ts +2 -85
- package/src/cli/platform/__tests__/CloudflareAdapter.spec.ts +22 -71
- package/src/cli/platform/adapters/CloudflareAdapter.ts +12 -11
- package/src/cli/platform/atoms/platformOptions.ts +9 -0
- package/src/cli/platform/schemas/cloudflare.ts +3 -2
- package/src/cli/platform/services/CloudflareApi.ts +164 -25
- package/src/cli/platform/services/WranglerApi.ts +0 -17
- package/src/core/Alepha.ts +9 -0
- package/src/react/form/index.ts +2 -0
- package/src/react/form/services/parseField.ts +163 -0
- package/src/react/form/services/prettyName.ts +19 -0
- package/src/react/head/providers/BrowserHeadProvider.ts +31 -10
- package/src/react/router/primitives/$page.ts +35 -12
- package/src/react/ui/atoms/uiAtom.ts +28 -0
- package/src/react/ui/components/ColorScheme.tsx +36 -0
- package/src/react/ui/hooks/useColorMode.ts +49 -0
- package/src/react/ui/hooks/useSidebarState.ts +26 -0
- package/src/react/ui/hooks/useTheme.ts +22 -0
- package/src/react/ui/index.ts +35 -0
- package/src/react/ui/services/UiPersistence.ts +41 -0
- package/src/router/TemplatedPathParser.ts +50 -51
- package/src/router/__tests__/RouterProvider.spec.ts +62 -0
- package/src/router/__tests__/TemplatedPathParser.spec.ts +18 -0
- package/src/router/providers/RouterProvider.ts +10 -5
- package/src/system/providers/NodeShellProvider.ts +1 -0
- package/src/topic/core/providers/TopicProvider.ts +1 -1
- package/dist/api/invitations/index.d.ts +0 -790
- package/dist/api/invitations/index.d.ts.map +0 -1
- package/dist/api/invitations/index.js +0 -662
- package/dist/api/invitations/index.js.map +0 -1
- package/dist/api/issues/index.d.ts +0 -810
- package/dist/api/issues/index.d.ts.map +0 -1
- package/dist/api/issues/index.js +0 -444
- package/dist/api/issues/index.js.map +0 -1
- package/dist/api/subscriptions/index.d.ts +0 -1692
- package/dist/api/subscriptions/index.d.ts.map +0 -1
- package/dist/api/subscriptions/index.js +0 -1867
- package/dist/api/subscriptions/index.js.map +0 -1
- package/dist/api/workflows/index.browser.js +0 -246
- package/dist/api/workflows/index.browser.js.map +0 -1
- package/dist/api/workflows/index.d.ts +0 -1618
- package/dist/api/workflows/index.d.ts.map +0 -1
- package/dist/api/workflows/index.js +0 -1495
- package/dist/api/workflows/index.js.map +0 -1
- package/src/api/invitations/__tests__/InvitationService.spec.ts +0 -439
- package/src/api/invitations/controllers/AdminInvitationController.ts +0 -86
- package/src/api/invitations/controllers/InvitationController.ts +0 -84
- package/src/api/invitations/entities/invitations.ts +0 -33
- package/src/api/invitations/index.ts +0 -58
- package/src/api/invitations/jobs/InvitationJobs.ts +0 -37
- package/src/api/invitations/providers/InvitationProvider.ts +0 -45
- package/src/api/invitations/schemas/createInvitationSchema.ts +0 -12
- package/src/api/invitations/schemas/invitationConfigAtom.ts +0 -20
- package/src/api/invitations/schemas/invitationQuerySchema.ts +0 -15
- package/src/api/invitations/schemas/invitationResourceSchema.ts +0 -6
- package/src/api/invitations/schemas/invitationWithResourceInfoSchema.ts +0 -22
- package/src/api/invitations/schemas/myInvitationsQuerySchema.ts +0 -10
- package/src/api/invitations/services/InvitationService.ts +0 -556
- package/src/api/issues/__tests__/IssueService.spec.ts +0 -263
- package/src/api/issues/controllers/AdminIssueController.ts +0 -149
- package/src/api/issues/controllers/IssueController.ts +0 -44
- package/src/api/issues/entities/issues.ts +0 -49
- package/src/api/issues/index.ts +0 -50
- package/src/api/issues/schemas/createIssueSchema.ts +0 -13
- package/src/api/issues/schemas/issueConfigAtom.ts +0 -13
- package/src/api/issues/schemas/issueQuerySchema.ts +0 -18
- package/src/api/issues/schemas/issueResourceSchema.ts +0 -6
- package/src/api/issues/schemas/myIssueQuerySchema.ts +0 -10
- package/src/api/issues/schemas/updateIssueSchema.ts +0 -13
- package/src/api/issues/services/IssueService.ts +0 -264
- package/src/api/jobs/__tests__/$job-middleware.spec.ts +0 -126
- package/src/api/jobs/__tests__/JobService.spec.ts +0 -31
- package/src/api/jobs/entities/jobExecutionLogEntity.ts +0 -13
- package/src/api/jobs/schemas/jobActivitySchema.ts +0 -15
- package/src/api/jobs/schemas/jobCronInfoSchema.ts +0 -22
- package/src/api/jobs/schemas/jobExecutionDetailResourceSchema.ts +0 -20
- package/src/api/jobs/schemas/jobFailureSchema.ts +0 -9
- package/src/api/jobs/schemas/jobQueueDepthSchema.ts +0 -14
- package/src/api/jobs/schemas/jobStatsSchema.ts +0 -14
- package/src/api/jobs/services/JobService-tests.ts +0 -157
- package/src/api/subscriptions/__tests__/BillingService.spec.ts +0 -218
- package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +0 -278
- package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +0 -212
- package/src/api/subscriptions/controllers/SubscriptionController.ts +0 -189
- package/src/api/subscriptions/entities/subscriptionEvents.ts +0 -54
- package/src/api/subscriptions/entities/subscriptions.ts +0 -68
- package/src/api/subscriptions/index.ts +0 -133
- package/src/api/subscriptions/jobs/SubscriptionJobs.ts +0 -382
- package/src/api/subscriptions/middleware/$requireLimit.ts +0 -50
- package/src/api/subscriptions/middleware/$requirePlan.ts +0 -49
- package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +0 -110
- package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +0 -8
- package/src/api/subscriptions/schemas/changePlanSchema.ts +0 -9
- package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +0 -11
- package/src/api/subscriptions/schemas/entitlementsSchema.ts +0 -21
- package/src/api/subscriptions/schemas/mrrSchema.ts +0 -13
- package/src/api/subscriptions/schemas/planDefinitionSchema.ts +0 -71
- package/src/api/subscriptions/schemas/planResourceSchema.ts +0 -25
- package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +0 -8
- package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +0 -19
- package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +0 -6
- package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +0 -32
- package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +0 -23
- package/src/api/subscriptions/services/BillingService.ts +0 -437
- package/src/api/subscriptions/services/SubscriptionConfig.ts +0 -56
- package/src/api/subscriptions/services/SubscriptionService.ts +0 -867
- package/src/api/subscriptions/services/UsageService.ts +0 -118
- package/src/api/workflows/__tests__/$workflow.spec.ts +0 -616
- package/src/api/workflows/controllers/AdminWorkflowController.ts +0 -191
- package/src/api/workflows/entities/workflowExecutions.ts +0 -74
- package/src/api/workflows/entities/workflowStepExecutions.ts +0 -74
- package/src/api/workflows/entities/workflowStepLogs.ts +0 -13
- package/src/api/workflows/index.browser.ts +0 -22
- package/src/api/workflows/index.ts +0 -115
- package/src/api/workflows/jobs/WorkflowJobs.ts +0 -77
- package/src/api/workflows/primitives/$workflow.ts +0 -202
- package/src/api/workflows/providers/WorkflowProvider.ts +0 -1284
- package/src/api/workflows/schemas/workflowActivitySchema.ts +0 -15
- package/src/api/workflows/schemas/workflowConfigAtom.ts +0 -51
- package/src/api/workflows/schemas/workflowExecutionDetailSchema.ts +0 -18
- package/src/api/workflows/schemas/workflowExecutionQuerySchema.ts +0 -26
- package/src/api/workflows/schemas/workflowExecutionResourceSchema.ts +0 -30
- package/src/api/workflows/schemas/workflowRegistrationSchema.ts +0 -26
- package/src/api/workflows/schemas/workflowStatsSchema.ts +0 -16
- package/src/api/workflows/schemas/workflowStepExecutionResourceSchema.ts +0 -15
- package/src/api/workflows/services/WorkflowService.ts +0 -382
- package/src/cli/core/templates/apiAppSecurityTs.ts +0 -43
- package/src/cli/core/templates/webAdminDashboardTsx.ts +0 -17
package/dist/api/jobs/index.js
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
1
|
import { $atom, $hook, $inject, $module, $state, Alepha, AlephaError, KIND, PipelinePrimitive, createPrimitive, t } from "alepha";
|
|
2
|
-
import { AlephaLock
|
|
2
|
+
import { AlephaLock } 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,
|
|
8
|
+
import { $entity, $repository, db, sql } from "alepha/orm";
|
|
9
9
|
import { DateTimeProvider } from "alepha/datetime";
|
|
10
|
-
//#region ../../src/api/jobs/schemas/
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
cron: t.text(),
|
|
25
|
-
lock: t.boolean(),
|
|
26
|
-
priority: t.enum([
|
|
27
|
-
"critical",
|
|
28
|
-
"high",
|
|
29
|
-
"normal",
|
|
30
|
-
"low"
|
|
31
|
-
]),
|
|
32
|
-
concurrency: t.integer(),
|
|
33
|
-
hasSchema: t.boolean(),
|
|
34
|
-
paused: t.boolean(),
|
|
35
|
-
lastExecution: t.optional(t.object({
|
|
36
|
-
id: t.uuid(),
|
|
37
|
-
status: t.text(),
|
|
38
|
-
startedAt: t.optional(t.datetime()),
|
|
39
|
-
completedAt: t.optional(t.datetime()),
|
|
40
|
-
error: t.optional(t.text())
|
|
10
|
+
//#region ../../src/api/jobs/schemas/jobExecutionQuerySchema.ts
|
|
11
|
+
const jobExecutionQuerySchema = t.object({
|
|
12
|
+
status: t.optional(t.enum([
|
|
13
|
+
"pending",
|
|
14
|
+
"running",
|
|
15
|
+
"scheduled",
|
|
16
|
+
"ok",
|
|
17
|
+
"error",
|
|
18
|
+
"cancelled"
|
|
19
|
+
])),
|
|
20
|
+
limit: t.optional(t.integer({
|
|
21
|
+
minimum: 1,
|
|
22
|
+
maximum: 200,
|
|
23
|
+
default: 20
|
|
41
24
|
}))
|
|
42
25
|
});
|
|
43
26
|
//#endregion
|
|
44
27
|
//#region ../../src/api/jobs/entities/jobExecutionEntity.ts
|
|
28
|
+
/**
|
|
29
|
+
* Job execution record.
|
|
30
|
+
*
|
|
31
|
+
* Stores durable state for queue-mode jobs (outbox pattern) and error records
|
|
32
|
+
* for cron-mode jobs. Successful executions are trimmed by the sweep to keep
|
|
33
|
+
* the last N rows per job (configurable via `jobConfig.keepLastSuccess`).
|
|
34
|
+
*
|
|
35
|
+
* Status transitions:
|
|
36
|
+
* - queue push → pending
|
|
37
|
+
* - worker claim → running
|
|
38
|
+
* - success → ok
|
|
39
|
+
* - terminal failure → error
|
|
40
|
+
* - retry → scheduled (with scheduledAt = now + backoff)
|
|
41
|
+
* - delay → scheduled (with scheduledAt = now + delay)
|
|
42
|
+
* - sweep picks due ones → pending
|
|
43
|
+
* - cancel → cancelled
|
|
44
|
+
*/
|
|
45
45
|
const jobExecutionEntity = $entity({
|
|
46
46
|
name: "job_executions",
|
|
47
47
|
schema: t.object({
|
|
@@ -50,14 +50,12 @@ const jobExecutionEntity = $entity({
|
|
|
50
50
|
updatedAt: db.updatedAt(),
|
|
51
51
|
jobName: t.text(),
|
|
52
52
|
key: t.optional(t.nullable(t.text())),
|
|
53
|
-
payload: t.optional(t.record(t.text(), t.any())),
|
|
54
53
|
status: db.default(t.enum([
|
|
55
54
|
"pending",
|
|
56
|
-
"scheduled",
|
|
57
|
-
"retrying",
|
|
58
55
|
"running",
|
|
59
|
-
"
|
|
60
|
-
"
|
|
56
|
+
"scheduled",
|
|
57
|
+
"ok",
|
|
58
|
+
"error",
|
|
61
59
|
"cancelled"
|
|
62
60
|
]), "pending"),
|
|
63
61
|
priority: db.default(t.integer({
|
|
@@ -66,12 +64,12 @@ const jobExecutionEntity = $entity({
|
|
|
66
64
|
}), 2),
|
|
67
65
|
attempt: db.default(t.integer(), 0),
|
|
68
66
|
maxAttempts: db.default(t.integer(), 1),
|
|
67
|
+
payload: t.optional(t.record(t.text(), t.any())),
|
|
69
68
|
scheduledAt: t.optional(t.datetime()),
|
|
70
69
|
startedAt: t.optional(t.datetime()),
|
|
71
70
|
completedAt: t.optional(t.datetime()),
|
|
72
|
-
result: t.optional(t.record(t.text(), t.any())),
|
|
73
71
|
error: t.optional(t.text()),
|
|
74
|
-
|
|
72
|
+
logs: t.optional(t.array(logEntrySchema)),
|
|
75
73
|
triggeredBy: t.optional(t.text()),
|
|
76
74
|
triggeredByName: t.optional(t.text()),
|
|
77
75
|
cancelledBy: t.optional(t.text()),
|
|
@@ -81,15 +79,9 @@ const jobExecutionEntity = $entity({
|
|
|
81
79
|
{ columns: [
|
|
82
80
|
"jobName",
|
|
83
81
|
"status",
|
|
84
|
-
"priority",
|
|
85
82
|
"scheduledAt"
|
|
86
83
|
] },
|
|
87
|
-
{ columns: [
|
|
88
|
-
"jobName",
|
|
89
|
-
"status",
|
|
90
|
-
"startedAt"
|
|
91
|
-
] },
|
|
92
|
-
{ columns: ["jobName", "completedAt"] },
|
|
84
|
+
{ columns: ["jobName", "startedAt"] },
|
|
93
85
|
{
|
|
94
86
|
columns: ["jobName", "key"],
|
|
95
87
|
unique: true
|
|
@@ -98,140 +90,60 @@ const jobExecutionEntity = $entity({
|
|
|
98
90
|
});
|
|
99
91
|
//#endregion
|
|
100
92
|
//#region ../../src/api/jobs/schemas/jobExecutionResourceSchema.ts
|
|
101
|
-
const
|
|
93
|
+
const jobExecutionResourceSchema = t.extend(jobExecutionEntity.schema, { can: t.object({
|
|
102
94
|
retry: t.boolean(),
|
|
103
95
|
cancel: t.boolean()
|
|
104
|
-
})
|
|
105
|
-
const jobExecutionResourceSchema = t.extend(jobExecutionEntity.schema, { can: jobExecutionCanSchema }, {
|
|
96
|
+
}) }, {
|
|
106
97
|
title: "JobExecutionResource",
|
|
107
|
-
description: "A job execution
|
|
108
|
-
});
|
|
109
|
-
//#endregion
|
|
110
|
-
//#region ../../src/api/jobs/schemas/jobExecutionDetailResourceSchema.ts
|
|
111
|
-
const jobExecutionDetailResourceSchema = t.extend(jobExecutionEntity.schema, {
|
|
112
|
-
can: jobExecutionCanSchema,
|
|
113
|
-
logs: t.optional(t.array(logEntrySchema))
|
|
114
|
-
}, {
|
|
115
|
-
title: "JobExecutionDetailResource",
|
|
116
|
-
description: "A job execution resource with logs."
|
|
117
|
-
});
|
|
118
|
-
//#endregion
|
|
119
|
-
//#region ../../src/api/jobs/schemas/jobExecutionQuerySchema.ts
|
|
120
|
-
const jobExecutionQuerySchema = t.extend(pageQuerySchema, {
|
|
121
|
-
job: t.optional(t.text({ description: "Filter by job name" })),
|
|
122
|
-
status: t.optional(t.enum([
|
|
123
|
-
"pending",
|
|
124
|
-
"scheduled",
|
|
125
|
-
"retrying",
|
|
126
|
-
"running",
|
|
127
|
-
"completed",
|
|
128
|
-
"dead",
|
|
129
|
-
"cancelled"
|
|
130
|
-
])),
|
|
131
|
-
priority: t.optional(t.enum([
|
|
132
|
-
"critical",
|
|
133
|
-
"high",
|
|
134
|
-
"normal",
|
|
135
|
-
"low"
|
|
136
|
-
])),
|
|
137
|
-
from: t.optional(t.datetime({ description: "From date (ISO)" })),
|
|
138
|
-
to: t.optional(t.datetime({ description: "To date (ISO)" }))
|
|
139
|
-
});
|
|
140
|
-
//#endregion
|
|
141
|
-
//#region ../../src/api/jobs/schemas/jobFailureSchema.ts
|
|
142
|
-
const jobFailureSchema = t.object({
|
|
143
|
-
jobName: t.text(),
|
|
144
|
-
failures: t.integer(),
|
|
145
|
-
lastError: t.optional(t.text())
|
|
146
|
-
});
|
|
147
|
-
//#endregion
|
|
148
|
-
//#region ../../src/api/jobs/schemas/jobQueueDepthSchema.ts
|
|
149
|
-
const jobQueueDepthSchema = t.object({
|
|
150
|
-
jobName: t.text(),
|
|
151
|
-
pending: t.integer(),
|
|
152
|
-
running: t.integer(),
|
|
153
|
-
scheduled: t.integer(),
|
|
154
|
-
retrying: t.integer(),
|
|
155
|
-
dead: t.integer(),
|
|
156
|
-
concurrency: t.integer(),
|
|
157
|
-
paused: t.boolean()
|
|
98
|
+
description: "A job execution row with derived actions."
|
|
158
99
|
});
|
|
159
100
|
//#endregion
|
|
160
101
|
//#region ../../src/api/jobs/schemas/jobRegistrationSchema.ts
|
|
161
102
|
const jobRegistrationSchema = t.object({
|
|
162
103
|
name: t.text(),
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
"push",
|
|
166
|
-
"both"
|
|
167
|
-
]),
|
|
104
|
+
description: t.optional(t.text()),
|
|
105
|
+
type: t.enum(["cron", "queue"]),
|
|
168
106
|
priority: t.enum([
|
|
169
107
|
"critical",
|
|
170
108
|
"high",
|
|
171
109
|
"normal",
|
|
172
110
|
"low"
|
|
173
111
|
]),
|
|
174
|
-
concurrency: t.integer(),
|
|
175
|
-
hasSchema: t.boolean(),
|
|
176
112
|
cron: t.optional(t.text()),
|
|
177
113
|
timeout: t.optional(t.text()),
|
|
178
114
|
retry: t.optional(t.object({
|
|
179
115
|
retries: t.integer(),
|
|
180
116
|
hasBackoff: t.boolean()
|
|
181
117
|
})),
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
registered: t.integer(),
|
|
188
|
-
running: t.integer(),
|
|
189
|
-
pending: t.integer(),
|
|
190
|
-
scheduled: t.integer(),
|
|
191
|
-
retrying: t.integer(),
|
|
192
|
-
dead: t.integer(),
|
|
193
|
-
completed: t.integer(),
|
|
194
|
-
failed: t.integer()
|
|
118
|
+
recent: t.object({
|
|
119
|
+
ok: t.integer(),
|
|
120
|
+
error: t.integer(),
|
|
121
|
+
lastRun: t.optional(t.datetime())
|
|
122
|
+
})
|
|
195
123
|
});
|
|
196
124
|
//#endregion
|
|
197
125
|
//#region ../../src/api/jobs/schemas/triggerJobSchema.ts
|
|
198
|
-
const triggerJobSchema = t.object({
|
|
199
|
-
name: t.text(),
|
|
200
|
-
payload: t.optional(t.record(t.text(), t.any()))
|
|
201
|
-
});
|
|
202
|
-
//#endregion
|
|
203
|
-
//#region ../../src/api/jobs/entities/jobExecutionLogEntity.ts
|
|
204
|
-
const jobExecutionLogEntity = $entity({
|
|
205
|
-
name: "job_execution_logs",
|
|
206
|
-
schema: t.object({
|
|
207
|
-
id: db.primaryKey(t.uuid()),
|
|
208
|
-
logs: t.array(logEntrySchema)
|
|
209
|
-
})
|
|
210
|
-
});
|
|
126
|
+
const triggerJobSchema = t.object({ payload: t.optional(t.record(t.text(), t.any())) });
|
|
211
127
|
//#endregion
|
|
212
128
|
//#region ../../src/api/jobs/schemas/jobConfigAtom.ts
|
|
213
129
|
const jobConfig = $atom({
|
|
214
130
|
name: "alepha.jobs",
|
|
215
|
-
description: "Configuration for the $job
|
|
131
|
+
description: "Configuration for the $job primitive.",
|
|
216
132
|
schema: t.object({
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}),
|
|
222
|
-
delayed: t.object({ interval: t.integer({ description: "Sweep interval (ms)." }) }),
|
|
223
|
-
logRetentionDays: t.integer({ description: "Days to keep completed/dead executions." }),
|
|
133
|
+
sweepInterval: t.integer({ description: "Sweep cron interval in milliseconds." }),
|
|
134
|
+
staleThreshold: t.integer({ description: "Pending age (ms) before the sweep re-dispatches it." }),
|
|
135
|
+
runTimeout: t.integer({ description: "Running age (ms) before assumed crash (fallback when no per-job timeout)." }),
|
|
136
|
+
keepLastSuccess: t.integer({ description: "Max successful rows to keep per job. Set 0 to disable and delete on success." }),
|
|
137
|
+
keepLastError: t.integer({ description: "Max error rows to keep per job." }),
|
|
224
138
|
logMaxEntries: t.integer({ description: "Max log entries captured per execution." }),
|
|
225
139
|
drainTimeout: t.integer({ description: "Max time (ms) to wait for in-flight jobs during shutdown." })
|
|
226
140
|
}),
|
|
227
141
|
default: {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
delayed: { interval: 3e5 },
|
|
234
|
-
logRetentionDays: 30,
|
|
142
|
+
sweepInterval: 3e5,
|
|
143
|
+
staleThreshold: 3e5,
|
|
144
|
+
runTimeout: 18e5,
|
|
145
|
+
keepLastSuccess: 10,
|
|
146
|
+
keepLastError: 10,
|
|
235
147
|
logMaxEntries: 100,
|
|
236
148
|
drainTimeout: 3e4
|
|
237
149
|
}
|
|
@@ -250,59 +162,185 @@ const PRIORITY_REVERSE = {
|
|
|
250
162
|
2: "normal",
|
|
251
163
|
3: "low"
|
|
252
164
|
};
|
|
253
|
-
|
|
165
|
+
const SWEEP_CRON = "*/5 * * * *";
|
|
166
|
+
/**
|
|
167
|
+
* Coordinates cron (scheduler) and queue (push) jobs with a durable outbox
|
|
168
|
+
* table and a single reconciliation sweep.
|
|
169
|
+
*
|
|
170
|
+
* Queue-mode flow:
|
|
171
|
+
* push() → INSERT row (pending) + queue.send({ executionId })
|
|
172
|
+
* worker → SELECT row → UPDATE running → handler → DELETE (ok) / UPDATE (error)
|
|
173
|
+
*
|
|
174
|
+
* Cron-mode flow:
|
|
175
|
+
* scheduler tick → handler runs inline → INSERT row only on error
|
|
176
|
+
*
|
|
177
|
+
* Sweep responsibilities (every `sweepInterval`):
|
|
178
|
+
* - re-enqueue pending rows older than `staleThreshold`
|
|
179
|
+
* - fail running rows older than `max(timeout*2, runTimeout)`
|
|
180
|
+
* - move `scheduled` rows with `scheduledAt <= now` to pending + enqueue
|
|
181
|
+
* - trim per-job history beyond `keepLastSuccess` / `keepLastError`
|
|
182
|
+
*/
|
|
183
|
+
var JobProvider = class {
|
|
254
184
|
alepha = $inject(Alepha);
|
|
255
185
|
dt = $inject(DateTimeProvider);
|
|
256
186
|
cronProvider = $inject(CronProvider);
|
|
257
|
-
lockProvider = $inject(LockProvider);
|
|
258
187
|
config = $state(jobConfig);
|
|
259
188
|
log = $logger();
|
|
260
189
|
executions = $repository(jobExecutionEntity);
|
|
261
|
-
executionLogs = $repository(jobExecutionLogEntity);
|
|
262
190
|
jobs = /* @__PURE__ */ new Map();
|
|
263
|
-
pausedJobs = /* @__PURE__ */ new Set();
|
|
264
191
|
inFlight = /* @__PURE__ */ new Set();
|
|
192
|
+
abortControllers = /* @__PURE__ */ new Map();
|
|
193
|
+
perExecutionLogs = /* @__PURE__ */ new Map();
|
|
194
|
+
stopping = false;
|
|
265
195
|
/**
|
|
266
|
-
*
|
|
267
|
-
* When null,
|
|
196
|
+
* Set by `JobQueueProvider` when `AlephaApiJobsQueue` is loaded.
|
|
197
|
+
* When null, queue-mode jobs cannot be pushed.
|
|
268
198
|
*/
|
|
269
199
|
queueDispatch = null;
|
|
270
|
-
logs = /* @__PURE__ */ new Map();
|
|
271
|
-
abortControllers = /* @__PURE__ */ new Map();
|
|
272
|
-
static SWEEP_CRON = "*/5 * * * *";
|
|
273
|
-
stopping = false;
|
|
274
|
-
workerId = "";
|
|
275
200
|
registerJob(name, options) {
|
|
276
201
|
if (this.jobs.has(name)) throw new AlephaError(`Job already registered: ${name}`);
|
|
202
|
+
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
|
+
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 type = options.cron ? "cron" : "queue";
|
|
277
205
|
this.jobs.set(name, {
|
|
278
206
|
name,
|
|
279
|
-
options
|
|
207
|
+
options,
|
|
208
|
+
type
|
|
280
209
|
});
|
|
281
|
-
this.log.debug(`Registered job '${name}'`, {
|
|
210
|
+
this.log.debug(`Registered ${type} job '${name}'`, {
|
|
282
211
|
cron: options.cron,
|
|
283
212
|
priority: options.priority ?? "normal",
|
|
284
213
|
retries: options.retry?.retries ?? 0
|
|
285
214
|
});
|
|
286
215
|
if (options.cron) this.cronProvider.createCronJob(name, options.cron, async () => {
|
|
287
216
|
try {
|
|
288
|
-
await this.
|
|
289
|
-
triggeredBy: "system",
|
|
290
|
-
triggeredByName: "system (cron)"
|
|
291
|
-
});
|
|
217
|
+
await this.runCron(name);
|
|
292
218
|
} catch (error) {
|
|
293
|
-
this.log.error(`Cron
|
|
219
|
+
this.log.error(`Cron tick failed for job '${name}'`, error);
|
|
294
220
|
}
|
|
295
221
|
});
|
|
296
222
|
}
|
|
297
|
-
/**
|
|
298
|
-
* Get all registered job definitions.
|
|
299
|
-
*/
|
|
300
223
|
getRegisteredJobs() {
|
|
301
224
|
return this.jobs;
|
|
302
225
|
}
|
|
226
|
+
async runCron(name) {
|
|
227
|
+
const registration = this.getRegistration(name);
|
|
228
|
+
if (registration.type !== "cron") throw new AlephaError(`Job '${name}' is not cron-mode`);
|
|
229
|
+
if (this.stopping) return;
|
|
230
|
+
const executionId = crypto.randomUUID();
|
|
231
|
+
const promise = this.executeInline(registration, executionId, {
|
|
232
|
+
payload: void 0,
|
|
233
|
+
attempt: 1,
|
|
234
|
+
triggeredBy: "system",
|
|
235
|
+
triggeredByName: "system (cron)"
|
|
236
|
+
});
|
|
237
|
+
this.inFlight.add(promise);
|
|
238
|
+
try {
|
|
239
|
+
await promise;
|
|
240
|
+
} finally {
|
|
241
|
+
this.inFlight.delete(promise);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Execute a cron handler inline. Records a row only on error (or always,
|
|
246
|
+
* when `record: 'all'`). No DB writes on the happy path by default.
|
|
247
|
+
*/
|
|
248
|
+
async executeInline(registration, executionId, ctx) {
|
|
249
|
+
const opts = registration.options;
|
|
250
|
+
const name = registration.name;
|
|
251
|
+
const record = opts.record ?? "error";
|
|
252
|
+
const contextId = this.alepha.context.createContextId();
|
|
253
|
+
this.perExecutionLogs.set(contextId, []);
|
|
254
|
+
const abortController = new AbortController();
|
|
255
|
+
this.abortControllers.set(executionId, abortController);
|
|
256
|
+
let timeoutId;
|
|
257
|
+
if (opts.timeout) {
|
|
258
|
+
const ms = this.dt.duration(opts.timeout).as("milliseconds");
|
|
259
|
+
timeoutId = setTimeout(() => abortController.abort(), ms);
|
|
260
|
+
}
|
|
261
|
+
const startedAt = this.dt.now();
|
|
262
|
+
try {
|
|
263
|
+
await this.alepha.context.run(async () => {
|
|
264
|
+
await this.alepha.events.emit("job:begin", {
|
|
265
|
+
name,
|
|
266
|
+
now: startedAt,
|
|
267
|
+
executionId
|
|
268
|
+
});
|
|
269
|
+
try {
|
|
270
|
+
await opts.handler({
|
|
271
|
+
payload: ctx.payload,
|
|
272
|
+
attempt: ctx.attempt,
|
|
273
|
+
now: startedAt,
|
|
274
|
+
signal: abortController.signal,
|
|
275
|
+
executionId
|
|
276
|
+
});
|
|
277
|
+
if (record === "all") await this.writeTerminalRow(executionId, name, "ok", {
|
|
278
|
+
payload: ctx.payload,
|
|
279
|
+
attempt: ctx.attempt,
|
|
280
|
+
startedAt,
|
|
281
|
+
error: void 0,
|
|
282
|
+
context: contextId,
|
|
283
|
+
triggeredBy: ctx.triggeredBy,
|
|
284
|
+
triggeredByName: ctx.triggeredByName
|
|
285
|
+
});
|
|
286
|
+
await this.alepha.events.emit("job:success", {
|
|
287
|
+
name,
|
|
288
|
+
executionId
|
|
289
|
+
}, { catch: true });
|
|
290
|
+
} catch (error) {
|
|
291
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
292
|
+
if (record !== "none") await this.writeTerminalRow(executionId, name, "error", {
|
|
293
|
+
payload: ctx.payload,
|
|
294
|
+
attempt: ctx.attempt,
|
|
295
|
+
startedAt,
|
|
296
|
+
error: err,
|
|
297
|
+
context: contextId,
|
|
298
|
+
triggeredBy: ctx.triggeredBy,
|
|
299
|
+
triggeredByName: ctx.triggeredByName
|
|
300
|
+
});
|
|
301
|
+
await this.alepha.events.emit("job:error", {
|
|
302
|
+
name,
|
|
303
|
+
error: err,
|
|
304
|
+
executionId
|
|
305
|
+
}, { catch: true });
|
|
306
|
+
} finally {
|
|
307
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
308
|
+
this.abortControllers.delete(executionId);
|
|
309
|
+
await this.alepha.events.emit("job:end", {
|
|
310
|
+
name,
|
|
311
|
+
executionId
|
|
312
|
+
}, { catch: true });
|
|
313
|
+
}
|
|
314
|
+
}, { context: contextId });
|
|
315
|
+
} finally {
|
|
316
|
+
this.perExecutionLogs.delete(contextId);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async writeTerminalRow(executionId, jobName, status, fields) {
|
|
320
|
+
try {
|
|
321
|
+
const logs = status === "error" ? this.snapshotLogs(fields.context) : void 0;
|
|
322
|
+
await this.executions.create({
|
|
323
|
+
id: executionId,
|
|
324
|
+
jobName,
|
|
325
|
+
status,
|
|
326
|
+
payload: fields.payload,
|
|
327
|
+
attempt: fields.attempt,
|
|
328
|
+
maxAttempts: fields.attempt,
|
|
329
|
+
startedAt: fields.startedAt.toISOString(),
|
|
330
|
+
completedAt: this.dt.nowISOString(),
|
|
331
|
+
error: fields.error?.message,
|
|
332
|
+
logs,
|
|
333
|
+
triggeredBy: fields.triggeredBy,
|
|
334
|
+
triggeredByName: fields.triggeredByName
|
|
335
|
+
});
|
|
336
|
+
} catch (e) {
|
|
337
|
+
this.log.warn(`Failed to write terminal row for ${executionId}`, e);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
303
340
|
async push(name, payload, options) {
|
|
304
|
-
const
|
|
305
|
-
if (
|
|
341
|
+
const registration = this.getRegistration(name);
|
|
342
|
+
if (registration.type !== "queue") throw new AlephaError(`Job '${name}' is not queue-mode (no schema declared). Use trigger() instead.`);
|
|
343
|
+
const opts = registration.options;
|
|
306
344
|
const validated = this.alepha.codec.validate(opts.schema, payload);
|
|
307
345
|
const priority = PRIORITY_MAP[options?.priority ?? opts.priority ?? "normal"];
|
|
308
346
|
const maxAttempts = (opts.retry?.retries ?? 0) + 1;
|
|
@@ -311,8 +349,15 @@ var JobProvider = class JobProvider {
|
|
|
311
349
|
if (options?.scheduledAt) scheduledAt = options.scheduledAt.toISOString();
|
|
312
350
|
else if (options?.delay) scheduledAt = this.dt.now().add(this.dt.duration(options.delay)).toISOString();
|
|
313
351
|
if (options?.key) {
|
|
314
|
-
const
|
|
315
|
-
|
|
352
|
+
const existing = await this.executions.findMany({
|
|
353
|
+
where: {
|
|
354
|
+
jobName: { eq: name },
|
|
355
|
+
key: { eq: options.key }
|
|
356
|
+
},
|
|
357
|
+
limit: 1
|
|
358
|
+
});
|
|
359
|
+
if (existing.length > 0) return existing[0].id;
|
|
360
|
+
const execution = await this.executions.create({
|
|
316
361
|
jobName: name,
|
|
317
362
|
key: options.key,
|
|
318
363
|
payload: validated,
|
|
@@ -320,14 +365,11 @@ var JobProvider = class JobProvider {
|
|
|
320
365
|
priority,
|
|
321
366
|
maxAttempts,
|
|
322
367
|
scheduledAt,
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}, {
|
|
326
|
-
target: ["jobName", "key"],
|
|
327
|
-
set: {},
|
|
328
|
-
now
|
|
368
|
+
triggeredBy: options.triggeredBy,
|
|
369
|
+
triggeredByName: options.triggeredByName
|
|
329
370
|
});
|
|
330
|
-
if (
|
|
371
|
+
if (status === "pending") await this.dispatchToQueue(name, execution.id);
|
|
372
|
+
else if (status === "scheduled" && scheduledAt) this.scheduleOptimisticDispatch(name, execution.id, scheduledAt);
|
|
331
373
|
return execution.id;
|
|
332
374
|
}
|
|
333
375
|
const execution = await this.executions.create({
|
|
@@ -336,43 +378,55 @@ var JobProvider = class JobProvider {
|
|
|
336
378
|
status,
|
|
337
379
|
priority,
|
|
338
380
|
maxAttempts,
|
|
339
|
-
scheduledAt
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
executionId: execution.id,
|
|
343
|
-
status,
|
|
344
|
-
priority: PRIORITY_REVERSE[priority]
|
|
381
|
+
scheduledAt,
|
|
382
|
+
triggeredBy: options?.triggeredBy,
|
|
383
|
+
triggeredByName: options?.triggeredByName
|
|
345
384
|
});
|
|
346
|
-
if (status === "pending"
|
|
385
|
+
if (status === "pending") await this.dispatchToQueue(name, execution.id);
|
|
386
|
+
else if (status === "scheduled" && scheduledAt) this.scheduleOptimisticDispatch(name, execution.id, scheduledAt);
|
|
347
387
|
return execution.id;
|
|
348
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Fire a local setTimeout so delayed/retrying rows dispatch as close to
|
|
391
|
+
* `scheduledAt` as possible, rather than waiting for the next sweep tick.
|
|
392
|
+
* No-op on stateless runtimes where timers won't survive (the sweep
|
|
393
|
+
* handles those).
|
|
394
|
+
*/
|
|
395
|
+
scheduleOptimisticDispatch(jobName, executionId, scheduledAt) {
|
|
396
|
+
const delayMs = Math.max(0, new Date(scheduledAt).getTime() - this.dt.nowMillis());
|
|
397
|
+
this.dt.createTimeout(() => {
|
|
398
|
+
this.dispatchScheduled(jobName, executionId);
|
|
399
|
+
}, delayMs);
|
|
400
|
+
}
|
|
349
401
|
async pushMany(name, items) {
|
|
350
402
|
if (items.length === 0) return [];
|
|
351
|
-
const
|
|
352
|
-
if (
|
|
403
|
+
const registration = this.getRegistration(name);
|
|
404
|
+
if (registration.type !== "queue") throw new AlephaError(`Job '${name}' is not queue-mode (no schema declared).`);
|
|
405
|
+
const opts = registration.options;
|
|
353
406
|
const maxAttempts = (opts.retry?.retries ?? 0) + 1;
|
|
354
407
|
const keyed = [];
|
|
355
|
-
const
|
|
408
|
+
const bulk = [];
|
|
356
409
|
for (const item of items) {
|
|
357
410
|
const validated = this.alepha.codec.validate(opts.schema, item.payload);
|
|
358
|
-
if (item.key)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
else {
|
|
363
|
-
const status = item.delay || item.scheduledAt ? "scheduled" : "pending";
|
|
364
|
-
let scheduledAt;
|
|
365
|
-
if (item.scheduledAt) scheduledAt = item.scheduledAt.toISOString();
|
|
366
|
-
else if (item.delay) scheduledAt = this.dt.now().add(this.dt.duration(item.delay)).toISOString();
|
|
367
|
-
bulkRows.push({
|
|
368
|
-
jobName: name,
|
|
369
|
-
payload: validated,
|
|
370
|
-
status,
|
|
371
|
-
priority: PRIORITY_MAP[item.priority ?? opts.priority ?? "normal"],
|
|
372
|
-
maxAttempts,
|
|
373
|
-
scheduledAt
|
|
411
|
+
if (item.key) {
|
|
412
|
+
keyed.push({
|
|
413
|
+
...item,
|
|
414
|
+
payload: validated
|
|
374
415
|
});
|
|
416
|
+
continue;
|
|
375
417
|
}
|
|
418
|
+
const status = item.delay || item.scheduledAt ? "scheduled" : "pending";
|
|
419
|
+
let scheduledAt;
|
|
420
|
+
if (item.scheduledAt) scheduledAt = item.scheduledAt.toISOString();
|
|
421
|
+
else if (item.delay) scheduledAt = this.dt.now().add(this.dt.duration(item.delay)).toISOString();
|
|
422
|
+
bulk.push({
|
|
423
|
+
jobName: name,
|
|
424
|
+
payload: validated,
|
|
425
|
+
status,
|
|
426
|
+
priority: PRIORITY_MAP[item.priority ?? opts.priority ?? "normal"],
|
|
427
|
+
maxAttempts,
|
|
428
|
+
scheduledAt
|
|
429
|
+
});
|
|
376
430
|
}
|
|
377
431
|
const ids = [];
|
|
378
432
|
for (const item of keyed) {
|
|
@@ -384,49 +438,47 @@ var JobProvider = class JobProvider {
|
|
|
384
438
|
});
|
|
385
439
|
ids.push(id);
|
|
386
440
|
}
|
|
387
|
-
if (
|
|
388
|
-
const created = await this.executions.createMany(
|
|
441
|
+
if (bulk.length > 0) {
|
|
442
|
+
const created = await this.executions.createMany(bulk);
|
|
389
443
|
for (const exec of created) {
|
|
390
444
|
ids.push(exec.id);
|
|
391
|
-
if (exec.status === "pending" && !this.stopping) await this.
|
|
445
|
+
if (exec.status === "pending" && !this.stopping) await this.dispatchToQueue(name, exec.id);
|
|
446
|
+
else if (exec.status === "scheduled" && exec.scheduledAt && !this.stopping) this.scheduleOptimisticDispatch(name, exec.id, exec.scheduledAt);
|
|
392
447
|
}
|
|
393
448
|
}
|
|
394
449
|
this.log.debug(`pushMany '${name}': ${ids.length} jobs created`, {
|
|
395
|
-
bulk:
|
|
450
|
+
bulk: bulk.length,
|
|
396
451
|
keyed: keyed.length
|
|
397
452
|
});
|
|
398
453
|
return ids;
|
|
399
454
|
}
|
|
455
|
+
async dispatchToQueue(jobName, executionId) {
|
|
456
|
+
if (this.stopping) return;
|
|
457
|
+
if (!this.queueDispatch) throw new AlephaError(`Queue-mode job '${jobName}' cannot be pushed: AlephaApiJobsQueue is not loaded. Add '.with(AlephaApiJobsQueue)' to your app.`);
|
|
458
|
+
await this.queueDispatch(jobName, executionId);
|
|
459
|
+
}
|
|
400
460
|
async trigger(name, context) {
|
|
401
|
-
const
|
|
402
|
-
if (
|
|
403
|
-
const
|
|
404
|
-
await this.
|
|
461
|
+
const registration = this.getRegistration(name);
|
|
462
|
+
if (registration.type === "cron") {
|
|
463
|
+
const executionId = crypto.randomUUID();
|
|
464
|
+
await this.executeInline(registration, executionId, {
|
|
465
|
+
payload: void 0,
|
|
466
|
+
attempt: 1,
|
|
405
467
|
triggeredBy: context?.triggeredBy,
|
|
406
468
|
triggeredByName: context?.triggeredByName
|
|
407
469
|
});
|
|
408
470
|
return;
|
|
409
471
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
status: "pending",
|
|
415
|
-
priority,
|
|
416
|
-
maxAttempts,
|
|
417
|
-
triggeredBy: context?.triggeredBy,
|
|
418
|
-
triggeredByName: context?.triggeredByName
|
|
419
|
-
});
|
|
420
|
-
this.log.debug(`Triggered job '${name}'`, {
|
|
421
|
-
executionId: execution.id,
|
|
422
|
-
triggeredBy: context?.triggeredByName ?? context?.triggeredBy
|
|
472
|
+
if (!context?.payload) throw new AlephaError(`Queue-mode job '${name}' requires a payload for manual trigger.`);
|
|
473
|
+
await this.push(name, context.payload, {
|
|
474
|
+
triggeredBy: context.triggeredBy,
|
|
475
|
+
triggeredByName: context.triggeredByName
|
|
423
476
|
});
|
|
424
|
-
if (!this.stopping) await this.scheduleProcessing(name, execution.id);
|
|
425
477
|
}
|
|
426
478
|
async cancel(executionId, context) {
|
|
427
479
|
const execution = await this.executions.findById(executionId);
|
|
428
480
|
if (!execution) throw new AlephaError(`Execution not found: ${executionId}`);
|
|
429
|
-
if (execution.status === "
|
|
481
|
+
if (execution.status === "ok" || execution.status === "error" || execution.status === "cancelled") throw new AlephaError(`Cannot cancel execution in '${execution.status}' status`);
|
|
430
482
|
const controller = this.abortControllers.get(executionId);
|
|
431
483
|
if (controller) controller.abort();
|
|
432
484
|
await this.executions.updateById(executionId, {
|
|
@@ -441,30 +493,17 @@ var JobProvider = class JobProvider {
|
|
|
441
493
|
cancelledBy: context?.cancelledByName ?? context?.cancelledBy
|
|
442
494
|
});
|
|
443
495
|
}
|
|
444
|
-
async
|
|
445
|
-
|
|
446
|
-
|
|
496
|
+
async processExecution(jobName, executionId) {
|
|
497
|
+
const registration = this.jobs.get(jobName);
|
|
498
|
+
if (!registration) {
|
|
499
|
+
this.log.warn(`Unknown job '${jobName}' — skipping execution`, { executionId });
|
|
447
500
|
return;
|
|
448
501
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
jobName: { eq: jobName },
|
|
452
|
-
status: { eq: "running" }
|
|
453
|
-
});
|
|
454
|
-
if (runningCount >= maxConcurrency) {
|
|
455
|
-
this.log.debug(`Job '${jobName}' at concurrency limit (${runningCount}/${maxConcurrency}), deferring`, { executionId });
|
|
502
|
+
if (registration.type !== "queue") {
|
|
503
|
+
this.log.warn(`Job '${jobName}' is not queue-mode — skipping`, { executionId });
|
|
456
504
|
return;
|
|
457
505
|
}
|
|
458
|
-
|
|
459
|
-
this.log.debug(`Dispatching job '${jobName}' via queue`, { executionId });
|
|
460
|
-
await this.queueDispatch(jobName, executionId);
|
|
461
|
-
} else {
|
|
462
|
-
this.log.debug(`Executing job '${jobName}' inline`, { executionId });
|
|
463
|
-
await this.processExecution(jobName, executionId);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
async processExecution(jobName, executionId) {
|
|
467
|
-
const promise = this.processExecutionInner(jobName, executionId);
|
|
506
|
+
const promise = this.processQueueExecution(registration, executionId);
|
|
468
507
|
this.inFlight.add(promise);
|
|
469
508
|
try {
|
|
470
509
|
await promise;
|
|
@@ -472,71 +511,63 @@ var JobProvider = class JobProvider {
|
|
|
472
511
|
this.inFlight.delete(promise);
|
|
473
512
|
}
|
|
474
513
|
}
|
|
475
|
-
async
|
|
476
|
-
const
|
|
514
|
+
async processQueueExecution(registration, executionId) {
|
|
515
|
+
const jobName = registration.name;
|
|
516
|
+
const opts = registration.options;
|
|
517
|
+
const record = opts.record ?? "error";
|
|
477
518
|
if (!await this.claim(executionId)) {
|
|
478
519
|
this.log.debug(`Execution ${executionId} already claimed, skipping`);
|
|
479
520
|
return;
|
|
480
521
|
}
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
this.
|
|
522
|
+
const execution = await this.executions.findById(executionId);
|
|
523
|
+
if (!execution) return;
|
|
524
|
+
const contextId = this.alepha.context.createContextId();
|
|
525
|
+
this.perExecutionLogs.set(contextId, []);
|
|
526
|
+
const abortController = new AbortController();
|
|
527
|
+
this.abortControllers.set(executionId, abortController);
|
|
528
|
+
let timeoutId;
|
|
529
|
+
if (opts.timeout) {
|
|
530
|
+
const ms = this.dt.duration(opts.timeout).as("milliseconds");
|
|
531
|
+
timeoutId = setTimeout(() => abortController.abort(), ms);
|
|
532
|
+
}
|
|
533
|
+
const now = this.dt.now();
|
|
484
534
|
try {
|
|
485
535
|
await this.alepha.context.run(async () => {
|
|
486
|
-
const abortController = new AbortController();
|
|
487
|
-
this.abortControllers.set(executionId, abortController);
|
|
488
|
-
let timeoutId;
|
|
489
|
-
const opts = registration.options;
|
|
490
|
-
if (opts.timeout) {
|
|
491
|
-
const ms = this.dt.duration(opts.timeout).as("milliseconds");
|
|
492
|
-
timeoutId = setTimeout(() => abortController.abort(), ms);
|
|
493
|
-
}
|
|
494
|
-
const now = this.dt.now();
|
|
495
536
|
await this.alepha.events.emit("job:begin", {
|
|
496
537
|
name: jobName,
|
|
497
538
|
now,
|
|
498
539
|
executionId
|
|
499
540
|
});
|
|
500
541
|
try {
|
|
501
|
-
const execution = await this.executions.findById(executionId);
|
|
502
|
-
const items = [];
|
|
503
|
-
if (execution?.payload) items.push({
|
|
504
|
-
id: executionId,
|
|
505
|
-
payload: execution.payload,
|
|
506
|
-
attempt: execution.attempt
|
|
507
|
-
});
|
|
508
|
-
this.log.debug(`Running job '${jobName}'`, {
|
|
509
|
-
executionId,
|
|
510
|
-
attempt: execution?.attempt,
|
|
511
|
-
items: items.length
|
|
512
|
-
});
|
|
513
542
|
await opts.handler({
|
|
514
|
-
|
|
543
|
+
payload: execution.payload,
|
|
544
|
+
attempt: execution.attempt,
|
|
515
545
|
now,
|
|
516
|
-
signal: abortController.signal
|
|
546
|
+
signal: abortController.signal,
|
|
547
|
+
executionId
|
|
517
548
|
});
|
|
518
|
-
await this.executions.updateById(executionId, {
|
|
519
|
-
status: "
|
|
549
|
+
if (record === "all" && this.config.keepLastSuccess > 0) await this.executions.updateById(executionId, {
|
|
550
|
+
status: "ok",
|
|
520
551
|
completedAt: this.dt.nowISOString(),
|
|
521
552
|
key: null
|
|
522
553
|
});
|
|
523
|
-
this.
|
|
524
|
-
await this.writeLogs(executionId, context);
|
|
554
|
+
else await this.executions.deleteById(executionId);
|
|
525
555
|
await this.alepha.events.emit("job:success", {
|
|
526
556
|
name: jobName,
|
|
527
557
|
executionId
|
|
528
558
|
}, { catch: true });
|
|
529
559
|
} catch (error) {
|
|
530
560
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
531
|
-
if (abortController.signal.aborted)
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
561
|
+
if (abortController.signal.aborted) {
|
|
562
|
+
if ((await this.executions.findById(executionId))?.status === "cancelled") {
|
|
563
|
+
await this.alepha.events.emit("job:cancel", {
|
|
564
|
+
name: jobName,
|
|
565
|
+
executionId
|
|
566
|
+
}, { catch: true });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
538
569
|
}
|
|
539
|
-
|
|
570
|
+
await this.handleFailure(executionId, registration, execution.attempt, err, contextId);
|
|
540
571
|
} finally {
|
|
541
572
|
if (timeoutId) clearTimeout(timeoutId);
|
|
542
573
|
this.abortControllers.delete(executionId);
|
|
@@ -544,39 +575,12 @@ var JobProvider = class JobProvider {
|
|
|
544
575
|
name: jobName,
|
|
545
576
|
executionId
|
|
546
577
|
}, { catch: true });
|
|
547
|
-
await this.dispatchNextPending(jobName);
|
|
548
578
|
}
|
|
549
|
-
}, { context });
|
|
579
|
+
}, { context: contextId });
|
|
550
580
|
} finally {
|
|
551
|
-
this.
|
|
581
|
+
this.perExecutionLogs.delete(contextId);
|
|
552
582
|
}
|
|
553
583
|
}
|
|
554
|
-
/**
|
|
555
|
-
* After a job finishes (success, failure, or cancel), dispatch any pending
|
|
556
|
-
* jobs that were deferred due to the concurrency limit.
|
|
557
|
-
*/
|
|
558
|
-
async dispatchNextPending(jobName) {
|
|
559
|
-
if (this.stopping || this.pausedJobs.has(jobName)) return;
|
|
560
|
-
const registration = this.jobs.get(jobName);
|
|
561
|
-
if (!registration) return;
|
|
562
|
-
const available = (registration.options.concurrency ?? 1) - await this.executions.count({
|
|
563
|
-
jobName: { eq: jobName },
|
|
564
|
-
status: { eq: "running" }
|
|
565
|
-
});
|
|
566
|
-
if (available <= 0) return;
|
|
567
|
-
const pending = await this.executions.findMany({
|
|
568
|
-
where: {
|
|
569
|
-
jobName: { eq: jobName },
|
|
570
|
-
status: { eq: "pending" }
|
|
571
|
-
},
|
|
572
|
-
orderBy: {
|
|
573
|
-
column: "priority",
|
|
574
|
-
direction: "asc"
|
|
575
|
-
},
|
|
576
|
-
limit: available
|
|
577
|
-
});
|
|
578
|
-
for (const exec of pending) await this.scheduleProcessing(jobName, exec.id);
|
|
579
|
-
}
|
|
580
584
|
async claim(executionId) {
|
|
581
585
|
const execution = await this.executions.findById(executionId);
|
|
582
586
|
if (!execution) return false;
|
|
@@ -587,45 +591,46 @@ var JobProvider = class JobProvider {
|
|
|
587
591
|
}, {
|
|
588
592
|
status: "running",
|
|
589
593
|
attempt: execution.attempt + 1,
|
|
590
|
-
startedAt: this.dt.nowISOString()
|
|
591
|
-
workerId: this.workerId
|
|
594
|
+
startedAt: this.dt.nowISOString()
|
|
592
595
|
});
|
|
593
596
|
return true;
|
|
594
597
|
} catch {
|
|
595
598
|
return false;
|
|
596
599
|
}
|
|
597
600
|
}
|
|
598
|
-
async handleFailure(executionId,
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
if (
|
|
603
|
-
const nextScheduledAt = this.computeBackoff(
|
|
604
|
-
this.log.info(`Job '${jobName}' failed, scheduling retry ${
|
|
601
|
+
async handleFailure(executionId, registration, currentAttempt, error, contextId) {
|
|
602
|
+
const jobName = registration.name;
|
|
603
|
+
const retry = registration.options.retry;
|
|
604
|
+
const maxAttempts = (retry?.retries ?? 0) + 1;
|
|
605
|
+
if (retry && currentAttempt + 1 < maxAttempts && (retry.when ? retry.when(error) : true)) {
|
|
606
|
+
const nextScheduledAt = this.computeBackoff(retry, currentAttempt + 1);
|
|
607
|
+
this.log.info(`Job '${jobName}' failed, scheduling retry ${currentAttempt + 1}/${maxAttempts}`, {
|
|
605
608
|
executionId,
|
|
606
609
|
error: error.message,
|
|
607
610
|
nextScheduledAt
|
|
608
611
|
});
|
|
609
612
|
await this.executions.updateById(executionId, {
|
|
610
|
-
status: "
|
|
613
|
+
status: "scheduled",
|
|
611
614
|
error: error.message,
|
|
612
|
-
scheduledAt: nextScheduledAt
|
|
615
|
+
scheduledAt: nextScheduledAt,
|
|
616
|
+
logs: this.snapshotLogs(contextId)
|
|
613
617
|
});
|
|
614
|
-
await this.writeLogs(executionId, context);
|
|
615
618
|
const delayMs = Math.max(0, new Date(nextScheduledAt).getTime() - this.dt.nowMillis());
|
|
616
|
-
this.dt.createTimeout(() =>
|
|
619
|
+
this.dt.createTimeout(() => {
|
|
620
|
+
this.dispatchScheduled(jobName, executionId);
|
|
621
|
+
}, delayMs);
|
|
617
622
|
} else {
|
|
618
|
-
this.log.info(`Job '${jobName}'
|
|
623
|
+
this.log.info(`Job '${jobName}' dead after ${currentAttempt} attempt(s)`, {
|
|
619
624
|
executionId,
|
|
620
625
|
error: error.message
|
|
621
626
|
});
|
|
622
627
|
await this.executions.updateById(executionId, {
|
|
623
|
-
status: "
|
|
628
|
+
status: "error",
|
|
624
629
|
error: error.message,
|
|
625
630
|
completedAt: this.dt.nowISOString(),
|
|
626
|
-
key: null
|
|
631
|
+
key: null,
|
|
632
|
+
logs: this.snapshotLogs(contextId)
|
|
627
633
|
});
|
|
628
|
-
await this.writeLogs(executionId, context);
|
|
629
634
|
}
|
|
630
635
|
await this.alepha.events.emit("job:error", {
|
|
631
636
|
name: jobName,
|
|
@@ -633,238 +638,160 @@ var JobProvider = class JobProvider {
|
|
|
633
638
|
executionId
|
|
634
639
|
}, { catch: true });
|
|
635
640
|
}
|
|
636
|
-
computeBackoff(
|
|
641
|
+
computeBackoff(retry, attempt) {
|
|
637
642
|
const now = this.dt.now();
|
|
638
|
-
if (!
|
|
639
|
-
if (Array.isArray(
|
|
640
|
-
|
|
641
|
-
return now.add(delay).toISOString();
|
|
642
|
-
}
|
|
643
|
-
const backoff = retryOpts.backoff;
|
|
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;
|
|
644
646
|
let delayMs = this.dt.duration(backoff.initial).as("milliseconds") * (backoff.factor ?? 2) ** (attempt - 1);
|
|
645
|
-
if (backoff.max)
|
|
646
|
-
const maxMs = this.dt.duration(backoff.max).as("milliseconds");
|
|
647
|
-
delayMs = Math.min(delayMs, maxMs);
|
|
648
|
-
}
|
|
647
|
+
if (backoff.max) delayMs = Math.min(delayMs, this.dt.duration(backoff.max).as("milliseconds"));
|
|
649
648
|
if (backoff.jitter) delayMs = delayMs * (.75 + Math.random() * .5);
|
|
650
649
|
return now.add(delayMs, "millisecond").toISOString();
|
|
651
650
|
}
|
|
652
|
-
|
|
653
|
-
const entries = this.
|
|
654
|
-
if (!entries || entries.length === 0) return;
|
|
655
|
-
const
|
|
656
|
-
if (
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
}
|
|
668
|
-
try {
|
|
669
|
-
await this.executionLogs.create({
|
|
670
|
-
id: executionId,
|
|
671
|
-
logs
|
|
672
|
-
});
|
|
673
|
-
} catch {
|
|
674
|
-
this.log.warn(`Failed to write logs for execution ${executionId}`);
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
async dispatchRetrying(jobName, executionId) {
|
|
678
|
-
if (this.stopping) return;
|
|
679
|
-
try {
|
|
680
|
-
await this.executions.updateOne({
|
|
681
|
-
id: { eq: executionId },
|
|
682
|
-
status: { eq: "retrying" }
|
|
683
|
-
}, { status: "pending" });
|
|
684
|
-
await this.scheduleProcessing(jobName, executionId);
|
|
685
|
-
} catch {}
|
|
651
|
+
snapshotLogs(contextId) {
|
|
652
|
+
const entries = this.perExecutionLogs.get(contextId);
|
|
653
|
+
if (!entries || entries.length === 0) return void 0;
|
|
654
|
+
const max = this.config.logMaxEntries;
|
|
655
|
+
if (max === 0) return void 0;
|
|
656
|
+
if (entries.length <= max) return [...entries];
|
|
657
|
+
const truncated = entries.slice(0, max);
|
|
658
|
+
truncated.push({
|
|
659
|
+
level: "WARN",
|
|
660
|
+
message: `Log entries truncated at ${max}`,
|
|
661
|
+
timestamp: this.dt.nowMillis(),
|
|
662
|
+
service: "alepha.jobs",
|
|
663
|
+
module: "JobProvider"
|
|
664
|
+
});
|
|
665
|
+
return truncated;
|
|
686
666
|
}
|
|
687
|
-
|
|
688
|
-
* Recovery Sweep (Section 5.1)
|
|
689
|
-
*
|
|
690
|
-
* Runs every `recovery.interval` (default: 1 minute).
|
|
691
|
-
* - Stale `pending` jobs older than `staleThreshold` → re-dispatch.
|
|
692
|
-
* - Crashed `running` jobs older than `max(job.timeout * 2, recovery.runTimeout)` → mark failed, apply retry policy.
|
|
693
|
-
*/
|
|
694
|
-
async recoverySweep() {
|
|
695
|
-
this.log.trace("Starting recovery sweep");
|
|
667
|
+
async sweep() {
|
|
696
668
|
if (this.stopping) return;
|
|
697
|
-
|
|
669
|
+
this.log.trace("Starting job sweep");
|
|
670
|
+
const now = this.dt.now();
|
|
671
|
+
const nowIso = now.toISOString();
|
|
698
672
|
try {
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
673
|
+
const dueWhere = this.executions.createQueryWhere();
|
|
674
|
+
dueWhere.status = { eq: "scheduled" };
|
|
675
|
+
dueWhere.scheduledAt = { lte: nowIso };
|
|
676
|
+
const due = await this.executions.findMany({
|
|
677
|
+
where: dueWhere,
|
|
678
|
+
orderBy: {
|
|
679
|
+
column: "priority",
|
|
680
|
+
direction: "asc"
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
for (const exec of due) {
|
|
684
|
+
if (!this.jobs.has(exec.jobName)) continue;
|
|
685
|
+
await this.executions.updateById(exec.id, { status: "pending" });
|
|
686
|
+
await this.dispatchToQueueSafe(exec.jobName, exec.id);
|
|
687
|
+
}
|
|
688
|
+
const staleIso = now.subtract(this.config.staleThreshold, "millisecond").toISOString();
|
|
689
|
+
const staleWhere = this.executions.createQueryWhere();
|
|
690
|
+
staleWhere.status = { eq: "pending" };
|
|
691
|
+
staleWhere.createdAt = { lte: staleIso };
|
|
692
|
+
const stale = await this.executions.findMany({
|
|
693
|
+
where: staleWhere,
|
|
706
694
|
orderBy: {
|
|
707
695
|
column: "priority",
|
|
708
696
|
direction: "asc"
|
|
709
697
|
}
|
|
710
698
|
});
|
|
711
|
-
for (const exec of
|
|
699
|
+
for (const exec of stale) {
|
|
712
700
|
if (!this.jobs.has(exec.jobName)) continue;
|
|
713
|
-
this.
|
|
714
|
-
await this.scheduleProcessing(exec.jobName, exec.id);
|
|
701
|
+
await this.dispatchToQueueSafe(exec.jobName, exec.id);
|
|
715
702
|
}
|
|
716
703
|
const runningWhere = this.executions.createQueryWhere();
|
|
717
704
|
runningWhere.status = { eq: "running" };
|
|
718
705
|
const running = await this.executions.findMany({ where: runningWhere });
|
|
719
706
|
const nowMs = now.valueOf();
|
|
720
707
|
for (const exec of running) {
|
|
721
|
-
const
|
|
722
|
-
if (!
|
|
708
|
+
const reg = this.jobs.get(exec.jobName);
|
|
709
|
+
if (!reg) continue;
|
|
723
710
|
if (this.abortControllers.has(exec.id)) continue;
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
if (
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
this.log.warn(`Recovery sweep: marking crashed job ${exec.jobName} (${exec.id}) as failed`);
|
|
731
|
-
const error = /* @__PURE__ */ new Error("Execution assumed crashed (recovered by sweep)");
|
|
732
|
-
await this.handleFailure(exec.id, exec.jobName, error, "");
|
|
711
|
+
const crashThresholdMs = reg.options.timeout ? this.dt.duration(reg.options.timeout).as("milliseconds") * 2 : this.config.runTimeout;
|
|
712
|
+
const startedAtMs = exec.startedAt ? new Date(exec.startedAt).getTime() : 0;
|
|
713
|
+
if (startedAtMs > 0 && nowMs - startedAtMs > crashThresholdMs) {
|
|
714
|
+
this.log.warn(`Sweep: marking crashed ${exec.jobName} (${exec.id}) as failed`);
|
|
715
|
+
const err = /* @__PURE__ */ new Error("Execution assumed crashed (recovered by sweep)");
|
|
716
|
+
await this.handleFailure(exec.id, reg, exec.attempt, err, "");
|
|
733
717
|
}
|
|
734
718
|
}
|
|
719
|
+
await this.trimRingBuffers();
|
|
735
720
|
} catch (e) {
|
|
736
|
-
this.log.error("
|
|
737
|
-
} finally {
|
|
738
|
-
await this.releaseLock("_alepha:jobs:recovery-lock");
|
|
721
|
+
this.log.error("Sweep failed", { error: e });
|
|
739
722
|
}
|
|
740
723
|
}
|
|
741
|
-
|
|
742
|
-
* Delayed Dispatch Sweep (Section 5.2)
|
|
743
|
-
*
|
|
744
|
-
* Runs every `delayed.interval` (default: 30 seconds).
|
|
745
|
-
* Scans for `scheduled` and `retrying` jobs where `scheduledAt <= now`,
|
|
746
|
-
* moves them to `pending`, and dispatches to the queue layer.
|
|
747
|
-
*/
|
|
748
|
-
async delayedDispatchSweep() {
|
|
749
|
-
this.log.trace("Starting delayed dispatch sweep");
|
|
750
|
-
if (this.stopping) return;
|
|
751
|
-
if (!await this.tryLock("_alepha:jobs:dispatch-lock", 6e4)) return;
|
|
724
|
+
async dispatchToQueueSafe(jobName, executionId) {
|
|
752
725
|
try {
|
|
753
|
-
|
|
754
|
-
const where = this.executions.createQueryWhere();
|
|
755
|
-
where.status = { inArray: ["scheduled", "retrying"] };
|
|
756
|
-
where.scheduledAt = { lte: now };
|
|
757
|
-
const ready = await this.executions.findMany({
|
|
758
|
-
where,
|
|
759
|
-
orderBy: {
|
|
760
|
-
column: "priority",
|
|
761
|
-
direction: "asc"
|
|
762
|
-
}
|
|
763
|
-
});
|
|
764
|
-
for (const exec of ready) {
|
|
765
|
-
if (!this.jobs.has(exec.jobName)) continue;
|
|
766
|
-
await this.executions.updateById(exec.id, { status: "pending" });
|
|
767
|
-
await this.scheduleProcessing(exec.jobName, exec.id);
|
|
768
|
-
}
|
|
726
|
+
await this.dispatchToQueue(jobName, executionId);
|
|
769
727
|
} catch (e) {
|
|
770
|
-
this.log.
|
|
771
|
-
} finally {
|
|
772
|
-
await this.releaseLock("_alepha:jobs:dispatch-lock");
|
|
728
|
+
this.log.warn(`Sweep failed to dispatch ${jobName} (${executionId})`, e);
|
|
773
729
|
}
|
|
774
730
|
}
|
|
775
731
|
/**
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
* Deletes completed/dead/cancelled execution records older than `logRetentionDays`.
|
|
732
|
+
* Move a row from `scheduled` → `pending` and dispatch it.
|
|
733
|
+
* Used by the optimistic retry/delay timer. If the sweep has already moved
|
|
734
|
+
* the row, or another worker has claimed it, the UPDATE guard fails silently.
|
|
780
735
|
*/
|
|
781
|
-
async
|
|
736
|
+
async dispatchScheduled(jobName, executionId) {
|
|
782
737
|
if (this.stopping) return;
|
|
783
738
|
try {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
739
|
+
await this.executions.updateOne({
|
|
740
|
+
id: { eq: executionId },
|
|
741
|
+
status: { eq: "scheduled" }
|
|
742
|
+
}, { status: "pending" });
|
|
743
|
+
await this.dispatchToQueueSafe(jobName, executionId);
|
|
744
|
+
} catch {}
|
|
745
|
+
}
|
|
746
|
+
async trimRingBuffers() {
|
|
747
|
+
for (const [jobName, reg] of this.jobs) {
|
|
748
|
+
const okLimit = reg.options.keep?.ok ?? this.config.keepLastSuccess;
|
|
749
|
+
const errLimit = reg.options.keep?.error ?? this.config.keepLastError;
|
|
750
|
+
if (okLimit > 0) await this.trimByStatus(jobName, "ok", okLimit);
|
|
751
|
+
if (errLimit > 0) await this.trimByStatus(jobName, "error", errLimit);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
async trimByStatus(jobName, status, keep) {
|
|
755
|
+
try {
|
|
756
|
+
const rows = await this.executions.findMany({
|
|
757
|
+
where: {
|
|
758
|
+
jobName: { eq: jobName },
|
|
759
|
+
status: { eq: status }
|
|
760
|
+
},
|
|
761
|
+
orderBy: {
|
|
762
|
+
column: "startedAt",
|
|
763
|
+
direction: "desc"
|
|
764
|
+
},
|
|
765
|
+
limit: keep + 50
|
|
795
766
|
});
|
|
796
|
-
if (
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
await this.executions.deleteMany({ id: { inArray:
|
|
800
|
-
this.log.
|
|
767
|
+
if (rows.length <= keep) return;
|
|
768
|
+
const toDelete = rows.slice(keep).map((r) => r.id);
|
|
769
|
+
if (toDelete.length > 0) {
|
|
770
|
+
await this.executions.deleteMany({ id: { inArray: toDelete } });
|
|
771
|
+
this.log.debug(`Trimmed ${toDelete.length} ${status} rows for '${jobName}'`);
|
|
801
772
|
}
|
|
802
773
|
} catch (e) {
|
|
803
|
-
this.log.
|
|
774
|
+
this.log.warn(`Failed to trim ${status} rows for '${jobName}'`, e);
|
|
804
775
|
}
|
|
805
776
|
}
|
|
806
|
-
pauseJob(name) {
|
|
807
|
-
this.getRegistration(name);
|
|
808
|
-
this.pausedJobs.add(name);
|
|
809
|
-
this.log.info(`Paused job '${name}'`);
|
|
810
|
-
}
|
|
811
|
-
async resumeJob(name) {
|
|
812
|
-
this.getRegistration(name);
|
|
813
|
-
this.pausedJobs.delete(name);
|
|
814
|
-
this.log.info(`Resumed job '${name}'`);
|
|
815
|
-
const pending = await this.executions.findMany({
|
|
816
|
-
where: {
|
|
817
|
-
jobName: { eq: name },
|
|
818
|
-
status: { eq: "pending" }
|
|
819
|
-
},
|
|
820
|
-
orderBy: {
|
|
821
|
-
column: "priority",
|
|
822
|
-
direction: "asc"
|
|
823
|
-
}
|
|
824
|
-
});
|
|
825
|
-
for (const exec of pending) await this.scheduleProcessing(name, exec.id);
|
|
826
|
-
}
|
|
827
|
-
isJobPaused(name) {
|
|
828
|
-
return this.pausedJobs.has(name);
|
|
829
|
-
}
|
|
830
|
-
getPausedJobs() {
|
|
831
|
-
return [...this.pausedJobs];
|
|
832
|
-
}
|
|
833
|
-
async tryLock(key, ttlMs) {
|
|
834
|
-
const lockValue = `${this.workerId},${this.dt.nowISOString()}`;
|
|
835
|
-
const [lockId] = (await this.lockProvider.set(key, lockValue, true, ttlMs)).split(",");
|
|
836
|
-
return lockId === this.workerId;
|
|
837
|
-
}
|
|
838
|
-
async releaseLock(key) {
|
|
839
|
-
await this.lockProvider.del(key);
|
|
840
|
-
}
|
|
841
777
|
onStart = $hook({
|
|
842
778
|
on: "start",
|
|
843
779
|
handler: async () => {
|
|
844
|
-
this.
|
|
780
|
+
if ([...this.jobs.values()].some((j) => j.type === "queue") && !this.queueDispatch) throw new AlephaError(`Queue-mode jobs are registered but no queue dispatcher is available. Add '.with(AlephaApiJobsQueue)' to your app.`);
|
|
845
781
|
this.log.info(`Job system OK`, {
|
|
846
|
-
|
|
847
|
-
|
|
782
|
+
dispatch: this.queueDispatch ? "queue" : "inline-only",
|
|
783
|
+
jobs: this.jobs.size
|
|
848
784
|
});
|
|
849
785
|
this.alepha.events.on("log", ({ entry }) => {
|
|
850
786
|
const ctx = entry.context;
|
|
851
787
|
if (!ctx) return;
|
|
852
|
-
const entries = this.
|
|
788
|
+
const entries = this.perExecutionLogs.get(ctx);
|
|
853
789
|
if (!entries) return;
|
|
854
790
|
entries.push(entry);
|
|
855
791
|
});
|
|
856
|
-
if (!this.alepha.isServerless())
|
|
857
|
-
|
|
858
|
-
await this.
|
|
859
|
-
}
|
|
860
|
-
this.cronProvider.createCronJob("_alepha:jobs:recovery", JobProvider.SWEEP_CRON, async () => {
|
|
861
|
-
await this.recoverySweep();
|
|
862
|
-
}, true);
|
|
863
|
-
this.cronProvider.createCronJob("_alepha:jobs:dispatch", JobProvider.SWEEP_CRON, async () => {
|
|
864
|
-
await this.delayedDispatchSweep();
|
|
865
|
-
}, true);
|
|
866
|
-
this.cronProvider.createCronJob("_alepha:jobs:log-purge", "0 0 * * *", async () => {
|
|
867
|
-
await this.logPurge();
|
|
792
|
+
if (!this.alepha.isServerless()) await this.sweep();
|
|
793
|
+
this.cronProvider.createCronJob("api:jobs:sweep", SWEEP_CRON, async () => {
|
|
794
|
+
await this.sweep();
|
|
868
795
|
}, true);
|
|
869
796
|
}
|
|
870
797
|
});
|
|
@@ -891,7 +818,12 @@ var JobProvider = class JobProvider {
|
|
|
891
818
|
//#endregion
|
|
892
819
|
//#region ../../src/api/jobs/primitives/$job.ts
|
|
893
820
|
/**
|
|
894
|
-
* Job primitive for defining scheduled
|
|
821
|
+
* Job primitive for defining scheduled (cron) or queued (push) tasks.
|
|
822
|
+
*
|
|
823
|
+
* A job must be either **cron-only** (pass `cron`) or **queue-only**
|
|
824
|
+
* (pass `schema`), never both. To run scheduled work that processes
|
|
825
|
+
* payloads, compose two jobs: a cron that pushes payloads, and a
|
|
826
|
+
* queue job that handles them.
|
|
895
827
|
*/
|
|
896
828
|
const $job = (options) => {
|
|
897
829
|
return createPrimitive(JobPrimitive, options);
|
|
@@ -899,7 +831,7 @@ const $job = (options) => {
|
|
|
899
831
|
var JobPrimitive = class extends PipelinePrimitive {
|
|
900
832
|
jobProvider = $inject(JobProvider);
|
|
901
833
|
get name() {
|
|
902
|
-
return `${this.config.service.name}.${this.config.propertyKey}`;
|
|
834
|
+
return this.options.name ?? `${this.config.service.name}.${this.config.propertyKey}`;
|
|
903
835
|
}
|
|
904
836
|
onInit() {
|
|
905
837
|
const handler = this.handler.run.bind(this.handler);
|
|
@@ -909,173 +841,148 @@ var JobPrimitive = class extends PipelinePrimitive {
|
|
|
909
841
|
});
|
|
910
842
|
}
|
|
911
843
|
/**
|
|
912
|
-
* Push a single payload
|
|
844
|
+
* Push a single payload to the queue (queue-mode only).
|
|
913
845
|
*/
|
|
914
846
|
async push(payload, options) {
|
|
915
|
-
if (Array.isArray(payload)) return await Promise.all(payload.map((p) => this.jobProvider.push(this.name, p, options)));
|
|
916
847
|
return this.jobProvider.push(this.name, payload, options);
|
|
917
848
|
}
|
|
918
849
|
/**
|
|
919
|
-
* Push multiple payloads
|
|
850
|
+
* Push multiple payloads at once (queue-mode only).
|
|
851
|
+
* Batched INSERT + batched queue send when supported.
|
|
920
852
|
*/
|
|
921
853
|
async pushMany(items) {
|
|
922
854
|
return this.jobProvider.pushMany(this.name, items);
|
|
923
855
|
}
|
|
924
856
|
/**
|
|
925
|
-
* Cancel a
|
|
857
|
+
* Cancel a pending or running execution.
|
|
926
858
|
*/
|
|
927
859
|
async cancel(executionId) {
|
|
928
860
|
return this.jobProvider.cancel(executionId);
|
|
929
861
|
}
|
|
930
862
|
/**
|
|
931
|
-
* Manually trigger
|
|
863
|
+
* Manually fire a cron-mode job, or trigger a queue-mode job with an explicit payload.
|
|
932
864
|
*/
|
|
933
865
|
async trigger(context) {
|
|
934
866
|
return this.jobProvider.trigger(this.name, context);
|
|
935
867
|
}
|
|
936
|
-
/**
|
|
937
|
-
* Pause this job. Pushed items are still accepted but processing is held.
|
|
938
|
-
*/
|
|
939
|
-
pause() {
|
|
940
|
-
this.jobProvider.pauseJob(this.name);
|
|
941
|
-
}
|
|
942
|
-
/**
|
|
943
|
-
* Resume a paused job and dispatch any pending items.
|
|
944
|
-
*/
|
|
945
|
-
async resume() {
|
|
946
|
-
return this.jobProvider.resumeJob(this.name);
|
|
947
|
-
}
|
|
948
|
-
/**
|
|
949
|
-
* Whether this job is currently paused.
|
|
950
|
-
*/
|
|
951
|
-
get paused() {
|
|
952
|
-
return this.jobProvider.isJobPaused(this.name);
|
|
953
|
-
}
|
|
954
868
|
};
|
|
955
869
|
$job[KIND] = JobPrimitive;
|
|
956
870
|
//#endregion
|
|
957
871
|
//#region ../../src/api/jobs/services/JobService.ts
|
|
872
|
+
/**
|
|
873
|
+
* Admin surface for the job system.
|
|
874
|
+
*
|
|
875
|
+
* Six methods: list jobs, list executions, get execution,
|
|
876
|
+
* trigger, retry, cancel. Everything else lives in events — any
|
|
877
|
+
* analytics/observability is an external concern that subscribes
|
|
878
|
+
* to `job:begin` / `job:success` / `job:error`.
|
|
879
|
+
*/
|
|
958
880
|
var JobService = class {
|
|
959
881
|
alepha = $inject(Alepha);
|
|
960
|
-
dt = $inject(DateTimeProvider);
|
|
961
882
|
log = $logger();
|
|
962
883
|
jobProvider = $inject(JobProvider);
|
|
963
|
-
database = $inject(DatabaseProvider);
|
|
964
884
|
executions = $repository(jobExecutionEntity);
|
|
965
|
-
executionLogs = $repository(jobExecutionLogEntity);
|
|
966
885
|
computeCan(status) {
|
|
967
886
|
return {
|
|
968
|
-
retry: status === "
|
|
969
|
-
cancel: status === "pending" || status === "running" || status === "scheduled"
|
|
887
|
+
retry: status === "error" || status === "cancelled",
|
|
888
|
+
cancel: status === "pending" || status === "running" || status === "scheduled"
|
|
970
889
|
};
|
|
971
890
|
}
|
|
972
891
|
/**
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
* - PostgreSQL: ISO string (timestamp comparison)
|
|
977
|
-
* - SQLite: epoch milliseconds (integer comparison)
|
|
892
|
+
* List every registered job with recent ok/error counts and lastRun.
|
|
893
|
+
* One aggregate query covers all jobs.
|
|
978
894
|
*/
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
async getStats(days) {
|
|
983
|
-
const jobs = this.jobProvider.getRegisteredJobs();
|
|
984
|
-
const periodAgo = this.toRawDate(this.dt.now().subtract(days ?? 1, "day").toISOString());
|
|
985
|
-
const row = (await this.executions.query((e) => sql`
|
|
895
|
+
async listJobs() {
|
|
896
|
+
const registry = this.jobProvider.getRegisteredJobs();
|
|
897
|
+
const aggRows = await this.executions.query((e) => sql`
|
|
986
898
|
SELECT
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
COUNT(*)
|
|
990
|
-
|
|
991
|
-
COUNT(*) FILTER (WHERE ${e.status} = 'dead') AS dead,
|
|
992
|
-
COUNT(*) FILTER (WHERE ${e.status} = 'completed' AND ${e.completedAt} >= ${periodAgo}) AS completed_24h,
|
|
993
|
-
COUNT(*) FILTER (WHERE ${e.status} = 'dead' AND ${e.completedAt} >= ${periodAgo}) AS failed_24h
|
|
899
|
+
${e.jobName} AS job_name,
|
|
900
|
+
${e.status} AS status,
|
|
901
|
+
COUNT(*) AS count,
|
|
902
|
+
MAX(${e.completedAt}) AS last_run
|
|
994
903
|
FROM ${e}
|
|
904
|
+
WHERE ${e.status} IN ('ok', 'error')
|
|
905
|
+
GROUP BY ${e.jobName}, ${e.status}
|
|
995
906
|
`, t.object({
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
registered: jobs.size,
|
|
1006
|
-
running: Number(row.running),
|
|
1007
|
-
pending: Number(row.pending),
|
|
1008
|
-
scheduled: Number(row.scheduled),
|
|
1009
|
-
retrying: Number(row.retrying),
|
|
1010
|
-
dead: Number(row.dead),
|
|
1011
|
-
completed: Number(row.completed_24h),
|
|
1012
|
-
failed: Number(row.failed_24h)
|
|
907
|
+
job_name: t.string(),
|
|
908
|
+
status: t.string(),
|
|
909
|
+
count: t.string(),
|
|
910
|
+
last_run: t.optional(t.nullable(t.union([t.string(), t.number()])))
|
|
911
|
+
}));
|
|
912
|
+
const toIso = (v) => {
|
|
913
|
+
if (v === null || v === void 0) return void 0;
|
|
914
|
+
if (typeof v === "number") return new Date(v).toISOString();
|
|
915
|
+
return v;
|
|
1013
916
|
};
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
917
|
+
const byJob = /* @__PURE__ */ new Map();
|
|
918
|
+
for (const row of aggRows) {
|
|
919
|
+
const entry = byJob.get(row.job_name) ?? {
|
|
920
|
+
ok: 0,
|
|
921
|
+
error: 0
|
|
922
|
+
};
|
|
923
|
+
if (row.status === "ok") entry.ok = Number(row.count);
|
|
924
|
+
if (row.status === "error") entry.error = Number(row.count);
|
|
925
|
+
const iso = toIso(row.last_run);
|
|
926
|
+
if (iso && (!entry.lastRun || iso > entry.lastRun)) entry.lastRun = iso;
|
|
927
|
+
byJob.set(row.job_name, entry);
|
|
928
|
+
}
|
|
1017
929
|
const result = [];
|
|
1018
|
-
for (const [name, reg] of
|
|
930
|
+
for (const [name, reg] of registry) {
|
|
1019
931
|
const opts = reg.options;
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
else type = "push";
|
|
1026
|
-
const registration = {
|
|
932
|
+
const counts = byJob.get(name) ?? {
|
|
933
|
+
ok: 0,
|
|
934
|
+
error: 0
|
|
935
|
+
};
|
|
936
|
+
result.push({
|
|
1027
937
|
name,
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
concurrency: opts.concurrency ?? 1,
|
|
1031
|
-
hasSchema,
|
|
938
|
+
description: opts.description,
|
|
939
|
+
type: reg.type,
|
|
1032
940
|
cron: opts.cron,
|
|
941
|
+
priority: opts.priority ?? "normal",
|
|
1033
942
|
timeout: opts.timeout ? String(opts.timeout) : void 0,
|
|
1034
943
|
retry: opts.retry ? {
|
|
1035
944
|
retries: opts.retry.retries,
|
|
1036
945
|
hasBackoff: Boolean(opts.retry.backoff)
|
|
1037
946
|
} : void 0,
|
|
1038
|
-
|
|
1039
|
-
};
|
|
1040
|
-
result.push(registration);
|
|
947
|
+
recent: counts
|
|
948
|
+
});
|
|
1041
949
|
}
|
|
1042
950
|
return result;
|
|
1043
951
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
952
|
+
/**
|
|
953
|
+
* Recent executions for a single job, ORDER BY startedAt DESC.
|
|
954
|
+
*/
|
|
955
|
+
async getExecutions(jobName, query = {}) {
|
|
956
|
+
if (!this.jobProvider.getRegisteredJobs().has(jobName)) throw new NotFoundError(`Job not found: ${jobName}`);
|
|
1046
957
|
const where = this.executions.createQueryWhere();
|
|
1047
|
-
|
|
958
|
+
where.jobName = { eq: jobName };
|
|
1048
959
|
if (query.status) where.status = { eq: query.status };
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
...
|
|
1058
|
-
|
|
1059
|
-
};
|
|
1060
|
-
const page = await this.executions.paginate(query, { where }, { count: true });
|
|
1061
|
-
return {
|
|
1062
|
-
...page,
|
|
1063
|
-
content: page.content.map((exec) => ({
|
|
1064
|
-
...exec,
|
|
1065
|
-
can: this.computeCan(exec.status)
|
|
1066
|
-
}))
|
|
1067
|
-
};
|
|
960
|
+
return (await this.executions.findMany({
|
|
961
|
+
where,
|
|
962
|
+
orderBy: {
|
|
963
|
+
column: "startedAt",
|
|
964
|
+
direction: "desc"
|
|
965
|
+
},
|
|
966
|
+
limit: query.limit ?? 20
|
|
967
|
+
})).map((row) => ({
|
|
968
|
+
...row,
|
|
969
|
+
can: this.computeCan(row.status)
|
|
970
|
+
}));
|
|
1068
971
|
}
|
|
972
|
+
/**
|
|
973
|
+
* Full execution detail (includes captured logs).
|
|
974
|
+
*/
|
|
1069
975
|
async getExecution(id) {
|
|
1070
976
|
const execution = await this.executions.findById(id);
|
|
1071
977
|
if (!execution) throw new NotFoundError(`Execution not found: ${id}`);
|
|
1072
|
-
const logRecord = await this.executionLogs.findById(id);
|
|
1073
978
|
return {
|
|
1074
979
|
...execution,
|
|
1075
|
-
can: this.computeCan(execution.status)
|
|
1076
|
-
logs: logRecord?.logs
|
|
980
|
+
can: this.computeCan(execution.status)
|
|
1077
981
|
};
|
|
1078
982
|
}
|
|
983
|
+
/**
|
|
984
|
+
* Manual trigger (cron jobs) or push-with-payload (queue jobs).
|
|
985
|
+
*/
|
|
1079
986
|
async triggerJob(name, context) {
|
|
1080
987
|
const job = this.alepha.primitives($job).find((j) => j.name === name);
|
|
1081
988
|
if (!job) throw new NotFoundError(`Job not found: ${name}`);
|
|
@@ -1083,18 +990,21 @@ var JobService = class {
|
|
|
1083
990
|
await job.trigger(context);
|
|
1084
991
|
return { ok: true };
|
|
1085
992
|
}
|
|
993
|
+
/**
|
|
994
|
+
* Retry a dead or cancelled execution by re-pushing with the original payload.
|
|
995
|
+
*/
|
|
1086
996
|
async retryExecution(id, context) {
|
|
1087
997
|
const execution = await this.executions.findById(id);
|
|
1088
998
|
if (!execution) throw new NotFoundError(`Execution not found: ${id}`);
|
|
1089
|
-
if (execution.status !== "
|
|
999
|
+
if (execution.status !== "error" && execution.status !== "cancelled") throw new AlephaError(`Cannot retry execution in '${execution.status}' status`);
|
|
1000
|
+
const job = this.alepha.primitives($job).find((j) => j.name === execution.jobName);
|
|
1001
|
+
if (!job) throw new NotFoundError(`Job not found: ${execution.jobName}`);
|
|
1090
1002
|
this.log.info(`Retrying execution ${id}`, {
|
|
1091
1003
|
jobName: execution.jobName,
|
|
1092
1004
|
previousStatus: execution.status,
|
|
1093
1005
|
triggeredBy: context?.triggeredByName ?? context?.triggeredBy
|
|
1094
1006
|
});
|
|
1095
|
-
|
|
1096
|
-
if (!job) throw new NotFoundError(`Job not found: ${execution.jobName}`);
|
|
1097
|
-
if (execution.payload) await job.push(execution.payload, {});
|
|
1007
|
+
if (execution.payload) await job.push(execution.payload);
|
|
1098
1008
|
else await job.trigger({
|
|
1099
1009
|
triggeredBy: context?.triggeredBy,
|
|
1100
1010
|
triggeredByName: context?.triggeredByName
|
|
@@ -1109,302 +1019,61 @@ var JobService = class {
|
|
|
1109
1019
|
});
|
|
1110
1020
|
return { ok: true };
|
|
1111
1021
|
}
|
|
1112
|
-
pauseJob(name, context) {
|
|
1113
|
-
const job = this.alepha.primitives($job).find((j) => j.name === name);
|
|
1114
|
-
if (!job) throw new NotFoundError(`Job not found: ${name}`);
|
|
1115
|
-
this.log.info(`Pausing job '${name}'`, { pausedBy: context?.pausedByName ?? context?.pausedBy });
|
|
1116
|
-
job.pause();
|
|
1117
|
-
return { ok: true };
|
|
1118
|
-
}
|
|
1119
|
-
async resumeJob(name, context) {
|
|
1120
|
-
const job = this.alepha.primitives($job).find((j) => j.name === name);
|
|
1121
|
-
if (!job) throw new NotFoundError(`Job not found: ${name}`);
|
|
1122
|
-
this.log.info(`Resuming job '${name}'`, { resumedBy: context?.resumedByName ?? context?.resumedBy });
|
|
1123
|
-
await job.resume();
|
|
1124
|
-
return { ok: true };
|
|
1125
|
-
}
|
|
1126
|
-
getPausedJobs() {
|
|
1127
|
-
return this.jobProvider.getPausedJobs();
|
|
1128
|
-
}
|
|
1129
|
-
async getCronJobs() {
|
|
1130
|
-
const jobs = this.jobProvider.getRegisteredJobs();
|
|
1131
|
-
const cronJobNames = [];
|
|
1132
|
-
for (const [name, reg] of jobs) if (reg.options.cron) cronJobNames.push(name);
|
|
1133
|
-
const lastByJob = await this.getLastExecutionPerJob(cronJobNames);
|
|
1134
|
-
const result = [];
|
|
1135
|
-
for (const name of cronJobNames) {
|
|
1136
|
-
const opts = jobs.get(name).options;
|
|
1137
|
-
const last = lastByJob.get(name);
|
|
1138
|
-
result.push({
|
|
1139
|
-
name,
|
|
1140
|
-
cron: opts.cron,
|
|
1141
|
-
lock: opts.lock !== false,
|
|
1142
|
-
priority: opts.priority ?? "normal",
|
|
1143
|
-
concurrency: opts.concurrency ?? 1,
|
|
1144
|
-
hasSchema: Boolean(opts.schema),
|
|
1145
|
-
paused: this.jobProvider.isJobPaused(name),
|
|
1146
|
-
lastExecution: last ? {
|
|
1147
|
-
id: last.id,
|
|
1148
|
-
status: last.status,
|
|
1149
|
-
startedAt: last.started_at ?? void 0,
|
|
1150
|
-
completedAt: last.completed_at ?? void 0,
|
|
1151
|
-
error: last.error ?? void 0
|
|
1152
|
-
} : void 0
|
|
1153
|
-
});
|
|
1154
|
-
}
|
|
1155
|
-
return result;
|
|
1156
|
-
}
|
|
1157
|
-
async getQueueDepth() {
|
|
1158
|
-
const jobs = this.jobProvider.getRegisteredJobs();
|
|
1159
|
-
const rows = await this.executions.query((e) => sql`
|
|
1160
|
-
SELECT
|
|
1161
|
-
${e.jobName} AS job_name,
|
|
1162
|
-
COUNT(*) FILTER (WHERE ${e.status} = 'pending') AS pending,
|
|
1163
|
-
COUNT(*) FILTER (WHERE ${e.status} = 'running') AS running,
|
|
1164
|
-
COUNT(*) FILTER (WHERE ${e.status} = 'scheduled') AS scheduled,
|
|
1165
|
-
COUNT(*) FILTER (WHERE ${e.status} = 'retrying') AS retrying,
|
|
1166
|
-
COUNT(*) FILTER (WHERE ${e.status} = 'dead') AS dead
|
|
1167
|
-
FROM ${e}
|
|
1168
|
-
WHERE ${e.status} IN ('pending', 'running', 'scheduled', 'retrying', 'dead')
|
|
1169
|
-
GROUP BY ${e.jobName}
|
|
1170
|
-
`, t.object({
|
|
1171
|
-
job_name: t.string(),
|
|
1172
|
-
pending: t.string(),
|
|
1173
|
-
running: t.string(),
|
|
1174
|
-
scheduled: t.string(),
|
|
1175
|
-
retrying: t.string(),
|
|
1176
|
-
dead: t.string()
|
|
1177
|
-
}));
|
|
1178
|
-
const counts = new Map(rows.map((r) => [r.job_name, r]));
|
|
1179
|
-
const result = [];
|
|
1180
|
-
for (const [name, reg] of jobs) {
|
|
1181
|
-
const row = counts.get(name);
|
|
1182
|
-
result.push({
|
|
1183
|
-
jobName: name,
|
|
1184
|
-
pending: Number(row?.pending ?? 0),
|
|
1185
|
-
running: Number(row?.running ?? 0),
|
|
1186
|
-
scheduled: Number(row?.scheduled ?? 0),
|
|
1187
|
-
retrying: Number(row?.retrying ?? 0),
|
|
1188
|
-
dead: Number(row?.dead ?? 0),
|
|
1189
|
-
concurrency: reg.options.concurrency ?? 1,
|
|
1190
|
-
paused: this.jobProvider.isJobPaused(name)
|
|
1191
|
-
});
|
|
1192
|
-
}
|
|
1193
|
-
return result;
|
|
1194
|
-
}
|
|
1195
|
-
async getActivity(days = 14) {
|
|
1196
|
-
if (this.database.dialect === "sqlite") return this.getActivitySqlite(days);
|
|
1197
|
-
return (await this.executions.query((e) => sql`
|
|
1198
|
-
WITH date_series AS (
|
|
1199
|
-
SELECT generate_series(
|
|
1200
|
-
CURRENT_DATE - ${days - 1}::int,
|
|
1201
|
-
CURRENT_DATE,
|
|
1202
|
-
'1 day'::interval
|
|
1203
|
-
)::date AS date
|
|
1204
|
-
)
|
|
1205
|
-
SELECT
|
|
1206
|
-
ds.date::text AS date,
|
|
1207
|
-
COALESCE(COUNT(*) FILTER (WHERE ${e.status} = 'completed'), 0) AS completed,
|
|
1208
|
-
COALESCE(COUNT(*) FILTER (WHERE ${e.status} = 'dead'), 0) AS failed
|
|
1209
|
-
FROM date_series ds
|
|
1210
|
-
LEFT JOIN ${e} ON DATE(${e.completedAt}) = ds.date
|
|
1211
|
-
AND ${e.status} IN ('completed', 'dead')
|
|
1212
|
-
GROUP BY ds.date
|
|
1213
|
-
ORDER BY ds.date ASC
|
|
1214
|
-
`, t.object({
|
|
1215
|
-
date: t.string(),
|
|
1216
|
-
completed: t.string(),
|
|
1217
|
-
failed: t.string()
|
|
1218
|
-
}))).map((row) => ({
|
|
1219
|
-
date: row.date,
|
|
1220
|
-
completed: Number(row.completed),
|
|
1221
|
-
failed: Number(row.failed)
|
|
1222
|
-
}));
|
|
1223
|
-
}
|
|
1224
|
-
async getActivitySqlite(days = 14) {
|
|
1225
|
-
const startDate = this.dt.now().subtract(days - 1, "day");
|
|
1226
|
-
const where = this.executions.createQueryWhere();
|
|
1227
|
-
where.status = { inArray: ["completed", "dead"] };
|
|
1228
|
-
where.completedAt = { gte: startDate.startOf("day").toISOString() };
|
|
1229
|
-
const executions = await this.executions.findMany({ where });
|
|
1230
|
-
const byDate = /* @__PURE__ */ new Map();
|
|
1231
|
-
for (let i = 0; i < days; i++) {
|
|
1232
|
-
const date = startDate.add(i, "day").format("YYYY-MM-DD");
|
|
1233
|
-
byDate.set(date, {
|
|
1234
|
-
completed: 0,
|
|
1235
|
-
failed: 0
|
|
1236
|
-
});
|
|
1237
|
-
}
|
|
1238
|
-
for (const exec of executions) {
|
|
1239
|
-
if (!exec.completedAt) continue;
|
|
1240
|
-
const date = this.dt.of(exec.completedAt).format("YYYY-MM-DD");
|
|
1241
|
-
const entry = byDate.get(date);
|
|
1242
|
-
if (!entry) continue;
|
|
1243
|
-
if (exec.status === "completed") entry.completed++;
|
|
1244
|
-
else entry.failed++;
|
|
1245
|
-
}
|
|
1246
|
-
return [...byDate.entries()].map(([date, counts]) => ({
|
|
1247
|
-
date,
|
|
1248
|
-
...counts
|
|
1249
|
-
}));
|
|
1250
|
-
}
|
|
1251
|
-
async getTopFailures(days) {
|
|
1252
|
-
const periodAgoIso = this.dt.now().subtract(days ?? 7, "day").toISOString();
|
|
1253
|
-
if (this.database.dialect === "sqlite") return this.getTopFailuresSqlite(periodAgoIso);
|
|
1254
|
-
return (await this.executions.query((e) => sql`
|
|
1255
|
-
SELECT
|
|
1256
|
-
${e.jobName} AS job_name,
|
|
1257
|
-
COUNT(*) AS failures,
|
|
1258
|
-
(ARRAY_AGG(${e.error} ORDER BY ${e.completedAt} DESC))[1] AS last_error
|
|
1259
|
-
FROM ${e}
|
|
1260
|
-
WHERE ${e.status} = 'dead'
|
|
1261
|
-
AND ${e.completedAt} >= ${periodAgoIso}
|
|
1262
|
-
GROUP BY ${e.jobName}
|
|
1263
|
-
ORDER BY failures DESC
|
|
1264
|
-
`, t.object({
|
|
1265
|
-
job_name: t.string(),
|
|
1266
|
-
failures: t.string(),
|
|
1267
|
-
last_error: t.optional(t.nullable(t.string()))
|
|
1268
|
-
}))).map((row) => ({
|
|
1269
|
-
jobName: row.job_name,
|
|
1270
|
-
failures: Number(row.failures),
|
|
1271
|
-
lastError: row.last_error ?? void 0
|
|
1272
|
-
}));
|
|
1273
|
-
}
|
|
1274
|
-
async getTopFailuresSqlite(periodAgoIso) {
|
|
1275
|
-
const where = this.executions.createQueryWhere();
|
|
1276
|
-
where.status = { eq: "dead" };
|
|
1277
|
-
where.completedAt = { gte: periodAgoIso };
|
|
1278
|
-
const failures = await this.executions.findMany({
|
|
1279
|
-
where,
|
|
1280
|
-
orderBy: {
|
|
1281
|
-
column: "completedAt",
|
|
1282
|
-
direction: "desc"
|
|
1283
|
-
}
|
|
1284
|
-
});
|
|
1285
|
-
const byJob = /* @__PURE__ */ new Map();
|
|
1286
|
-
for (const exec of failures) {
|
|
1287
|
-
const entry = byJob.get(exec.jobName) ?? { failures: 0 };
|
|
1288
|
-
entry.failures++;
|
|
1289
|
-
if (!entry.lastError) entry.lastError = exec.error ?? void 0;
|
|
1290
|
-
byJob.set(exec.jobName, entry);
|
|
1291
|
-
}
|
|
1292
|
-
return [...byJob.entries()].map(([jobName, data]) => ({
|
|
1293
|
-
jobName,
|
|
1294
|
-
failures: data.failures,
|
|
1295
|
-
lastError: data.lastError
|
|
1296
|
-
})).sort((a, b) => b.failures - a.failures);
|
|
1297
|
-
}
|
|
1298
|
-
/**
|
|
1299
|
-
* Fetch the most recent execution per job name.
|
|
1300
|
-
*
|
|
1301
|
-
* - PostgreSQL: uses `DISTINCT ON` for a single-pass query
|
|
1302
|
-
* - SQLite: uses ORM queries (one per job name) since `DISTINCT ON` is not supported
|
|
1303
|
-
*/
|
|
1304
|
-
async getLastExecutionPerJob(jobNames) {
|
|
1305
|
-
if (jobNames.length === 0) return /* @__PURE__ */ new Map();
|
|
1306
|
-
if (this.database.dialect === "sqlite") {
|
|
1307
|
-
const result = /* @__PURE__ */ new Map();
|
|
1308
|
-
for (const name of jobNames) {
|
|
1309
|
-
const rows = await this.executions.findMany({
|
|
1310
|
-
where: { jobName: { eq: name } },
|
|
1311
|
-
orderBy: {
|
|
1312
|
-
column: "createdAt",
|
|
1313
|
-
direction: "desc"
|
|
1314
|
-
},
|
|
1315
|
-
limit: 1
|
|
1316
|
-
});
|
|
1317
|
-
if (rows[0]) result.set(name, {
|
|
1318
|
-
id: rows[0].id,
|
|
1319
|
-
job_name: rows[0].jobName,
|
|
1320
|
-
status: rows[0].status,
|
|
1321
|
-
started_at: rows[0].startedAt,
|
|
1322
|
-
completed_at: rows[0].completedAt,
|
|
1323
|
-
error: rows[0].error
|
|
1324
|
-
});
|
|
1325
|
-
}
|
|
1326
|
-
return result;
|
|
1327
|
-
}
|
|
1328
|
-
const schema = t.object({
|
|
1329
|
-
id: t.string(),
|
|
1330
|
-
job_name: t.string(),
|
|
1331
|
-
status: t.string(),
|
|
1332
|
-
started_at: t.optional(t.nullable(t.string())),
|
|
1333
|
-
completed_at: t.optional(t.nullable(t.string())),
|
|
1334
|
-
error: t.optional(t.nullable(t.string()))
|
|
1335
|
-
});
|
|
1336
|
-
const rows = await this.executions.query((e) => sql`
|
|
1337
|
-
SELECT DISTINCT ON (${e.jobName})
|
|
1338
|
-
${e.id}, ${e.jobName} AS job_name, ${e.status},
|
|
1339
|
-
${e.startedAt} AS started_at, ${e.completedAt} AS completed_at, ${e.error}
|
|
1340
|
-
FROM ${e}
|
|
1341
|
-
WHERE ${e.jobName} IN (${sql.join(jobNames.map((n) => sql`${n}`), sql`, `)})
|
|
1342
|
-
ORDER BY ${e.jobName}, ${e.createdAt} DESC
|
|
1343
|
-
`, schema);
|
|
1344
|
-
return new Map(rows.map((r) => [r.job_name, r]));
|
|
1345
|
-
}
|
|
1346
1022
|
};
|
|
1347
1023
|
//#endregion
|
|
1348
1024
|
//#region ../../src/api/jobs/controllers/AdminJobController.ts
|
|
1025
|
+
/**
|
|
1026
|
+
* Minimal admin surface for the job system. Six endpoints.
|
|
1027
|
+
*/
|
|
1349
1028
|
var AdminJobController = class {
|
|
1350
1029
|
url = "/jobs";
|
|
1351
1030
|
group = "admin:jobs";
|
|
1352
1031
|
jobService = $inject(JobService);
|
|
1353
|
-
|
|
1354
|
-
path: `${this.url}/stats`,
|
|
1355
|
-
group: this.group,
|
|
1356
|
-
use: [$secure({ permissions: ["admin:job:read"] })],
|
|
1357
|
-
schema: {
|
|
1358
|
-
query: jobActivityQuerySchema,
|
|
1359
|
-
response: jobStatsSchema
|
|
1360
|
-
},
|
|
1361
|
-
handler: ({ query }) => this.jobService.getStats(query.days)
|
|
1362
|
-
});
|
|
1363
|
-
getJobRegistry = $action({
|
|
1032
|
+
listJobs = $action({
|
|
1364
1033
|
path: this.url,
|
|
1365
1034
|
group: this.group,
|
|
1366
1035
|
use: [$secure({ permissions: ["admin:job:read"] })],
|
|
1367
1036
|
schema: { response: t.array(jobRegistrationSchema) },
|
|
1368
|
-
handler: () => this.jobService.
|
|
1037
|
+
handler: () => this.jobService.listJobs()
|
|
1369
1038
|
});
|
|
1370
|
-
|
|
1371
|
-
path: `${this.url}/executions`,
|
|
1039
|
+
listExecutions = $action({
|
|
1040
|
+
path: `${this.url}/:name/executions`,
|
|
1372
1041
|
group: this.group,
|
|
1373
1042
|
use: [$secure({ permissions: ["admin:job:read"] })],
|
|
1374
1043
|
schema: {
|
|
1044
|
+
params: t.object({ name: t.text() }),
|
|
1375
1045
|
query: jobExecutionQuerySchema,
|
|
1376
|
-
response: t.
|
|
1046
|
+
response: t.array(jobExecutionResourceSchema)
|
|
1377
1047
|
},
|
|
1378
|
-
handler: ({ query }) => this.jobService.
|
|
1048
|
+
handler: ({ params, query }) => this.jobService.getExecutions(params.name, query)
|
|
1379
1049
|
});
|
|
1380
|
-
|
|
1050
|
+
getExecution = $action({
|
|
1381
1051
|
path: `${this.url}/executions/:id`,
|
|
1382
1052
|
group: this.group,
|
|
1383
1053
|
use: [$secure({ permissions: ["admin:job:read"] })],
|
|
1384
1054
|
schema: {
|
|
1385
1055
|
params: t.object({ id: t.uuid() }),
|
|
1386
|
-
response:
|
|
1056
|
+
response: jobExecutionResourceSchema
|
|
1387
1057
|
},
|
|
1388
1058
|
handler: ({ params }) => this.jobService.getExecution(params.id)
|
|
1389
1059
|
});
|
|
1390
1060
|
triggerJob = $action({
|
|
1391
1061
|
method: "POST",
|
|
1392
|
-
path: `${this.url}/trigger`,
|
|
1062
|
+
path: `${this.url}/:name/trigger`,
|
|
1393
1063
|
group: this.group,
|
|
1394
1064
|
use: [$secure({ permissions: ["admin:job:trigger"] })],
|
|
1395
1065
|
schema: {
|
|
1066
|
+
params: t.object({ name: t.text() }),
|
|
1396
1067
|
body: triggerJobSchema,
|
|
1397
1068
|
response: okSchema
|
|
1398
1069
|
},
|
|
1399
|
-
handler:
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
});
|
|
1405
|
-
}
|
|
1070
|
+
handler: ({ params, body, user }) => this.jobService.triggerJob(params.name, {
|
|
1071
|
+
payload: body.payload,
|
|
1072
|
+
triggeredBy: user?.id,
|
|
1073
|
+
triggeredByName: user?.name
|
|
1074
|
+
})
|
|
1406
1075
|
});
|
|
1407
|
-
|
|
1076
|
+
retryExecution = $action({
|
|
1408
1077
|
method: "POST",
|
|
1409
1078
|
path: `${this.url}/executions/:id/retry`,
|
|
1410
1079
|
group: this.group,
|
|
@@ -1413,14 +1082,12 @@ var AdminJobController = class {
|
|
|
1413
1082
|
params: t.object({ id: t.uuid() }),
|
|
1414
1083
|
response: okSchema
|
|
1415
1084
|
},
|
|
1416
|
-
handler:
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
});
|
|
1421
|
-
}
|
|
1085
|
+
handler: ({ params, user }) => this.jobService.retryExecution(params.id, {
|
|
1086
|
+
triggeredBy: user?.id,
|
|
1087
|
+
triggeredByName: user?.name
|
|
1088
|
+
})
|
|
1422
1089
|
});
|
|
1423
|
-
|
|
1090
|
+
cancelExecution = $action({
|
|
1424
1091
|
method: "POST",
|
|
1425
1092
|
path: `${this.url}/executions/:id/cancel`,
|
|
1426
1093
|
group: this.group,
|
|
@@ -1429,101 +1096,25 @@ var AdminJobController = class {
|
|
|
1429
1096
|
params: t.object({ id: t.uuid() }),
|
|
1430
1097
|
response: okSchema
|
|
1431
1098
|
},
|
|
1432
|
-
handler:
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
});
|
|
1437
|
-
}
|
|
1438
|
-
});
|
|
1439
|
-
getJobActivity = $action({
|
|
1440
|
-
path: `${this.url}/activity`,
|
|
1441
|
-
group: this.group,
|
|
1442
|
-
use: [$secure({ permissions: ["admin:job:read"] })],
|
|
1443
|
-
schema: {
|
|
1444
|
-
query: jobActivityQuerySchema,
|
|
1445
|
-
response: t.array(jobActivityPointSchema)
|
|
1446
|
-
},
|
|
1447
|
-
handler: ({ query }) => this.jobService.getActivity(query.days)
|
|
1448
|
-
});
|
|
1449
|
-
getCronJobs = $action({
|
|
1450
|
-
path: `${this.url}/cron`,
|
|
1451
|
-
group: this.group,
|
|
1452
|
-
use: [$secure({ permissions: ["admin:job:read"] })],
|
|
1453
|
-
schema: { response: t.array(jobCronInfoSchema) },
|
|
1454
|
-
handler: () => this.jobService.getCronJobs()
|
|
1455
|
-
});
|
|
1456
|
-
getJobQueueDepth = $action({
|
|
1457
|
-
path: `${this.url}/queue`,
|
|
1458
|
-
group: this.group,
|
|
1459
|
-
use: [$secure({ permissions: ["admin:job:read"] })],
|
|
1460
|
-
schema: { response: t.array(jobQueueDepthSchema) },
|
|
1461
|
-
handler: () => this.jobService.getQueueDepth()
|
|
1462
|
-
});
|
|
1463
|
-
getJobTopFailures = $action({
|
|
1464
|
-
path: `${this.url}/failures`,
|
|
1465
|
-
group: this.group,
|
|
1466
|
-
use: [$secure({ permissions: ["admin:job:read"] })],
|
|
1467
|
-
schema: {
|
|
1468
|
-
query: jobActivityQuerySchema,
|
|
1469
|
-
response: t.array(jobFailureSchema)
|
|
1470
|
-
},
|
|
1471
|
-
handler: ({ query }) => this.jobService.getTopFailures(query.days)
|
|
1472
|
-
});
|
|
1473
|
-
pauseJob = $action({
|
|
1474
|
-
method: "POST",
|
|
1475
|
-
path: `${this.url}/pause`,
|
|
1476
|
-
group: this.group,
|
|
1477
|
-
use: [$secure({ permissions: ["admin:job:trigger"] })],
|
|
1478
|
-
schema: {
|
|
1479
|
-
body: t.object({ name: t.text() }),
|
|
1480
|
-
response: okSchema
|
|
1481
|
-
},
|
|
1482
|
-
handler: ({ body, user }) => {
|
|
1483
|
-
return this.jobService.pauseJob(body.name, {
|
|
1484
|
-
pausedBy: user?.id,
|
|
1485
|
-
pausedByName: user?.name
|
|
1486
|
-
});
|
|
1487
|
-
}
|
|
1488
|
-
});
|
|
1489
|
-
resumeJob = $action({
|
|
1490
|
-
method: "POST",
|
|
1491
|
-
path: `${this.url}/resume`,
|
|
1492
|
-
group: this.group,
|
|
1493
|
-
use: [$secure({ permissions: ["admin:job:trigger"] })],
|
|
1494
|
-
schema: {
|
|
1495
|
-
body: t.object({ name: t.text() }),
|
|
1496
|
-
response: okSchema
|
|
1497
|
-
},
|
|
1498
|
-
handler: async ({ body, user }) => {
|
|
1499
|
-
return this.jobService.resumeJob(body.name, {
|
|
1500
|
-
resumedBy: user?.id,
|
|
1501
|
-
resumedByName: user?.name
|
|
1502
|
-
});
|
|
1503
|
-
}
|
|
1504
|
-
});
|
|
1505
|
-
getPausedJobs = $action({
|
|
1506
|
-
path: `${this.url}/paused`,
|
|
1507
|
-
group: this.group,
|
|
1508
|
-
use: [$secure({ permissions: ["admin:job:read"] })],
|
|
1509
|
-
schema: { response: t.array(t.text()) },
|
|
1510
|
-
handler: () => this.jobService.getPausedJobs()
|
|
1099
|
+
handler: ({ params, user }) => this.jobService.cancelExecution(params.id, {
|
|
1100
|
+
cancelledBy: user?.id,
|
|
1101
|
+
cancelledByName: user?.name
|
|
1102
|
+
})
|
|
1511
1103
|
});
|
|
1512
1104
|
};
|
|
1513
1105
|
//#endregion
|
|
1514
1106
|
//#region ../../src/api/jobs/providers/JobQueueProvider.ts
|
|
1515
1107
|
/**
|
|
1516
|
-
*
|
|
1108
|
+
* Plumbs outbox-style dispatch through `AlephaQueue`.
|
|
1517
1109
|
*
|
|
1518
|
-
*
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
1521
|
-
* omitted so jobs execute inline without requiring an external queue resource.
|
|
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.
|
|
1522
1113
|
*/
|
|
1523
1114
|
var JobQueueProvider = class {
|
|
1524
1115
|
jobProvider = $inject(JobProvider);
|
|
1525
1116
|
queue = $queue({
|
|
1526
|
-
name: "
|
|
1117
|
+
name: "api:jobs:dispatch",
|
|
1527
1118
|
schema: t.object({
|
|
1528
1119
|
jobName: t.text(),
|
|
1529
1120
|
executionId: t.text()
|
|
@@ -1532,15 +1123,12 @@ var JobQueueProvider = class {
|
|
|
1532
1123
|
await this.jobProvider.processExecution(msg.payload.jobName, msg.payload.executionId);
|
|
1533
1124
|
}
|
|
1534
1125
|
});
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
}
|
|
1541
|
-
/**
|
|
1542
|
-
* Push a job execution onto the queue for async processing.
|
|
1543
|
-
*/
|
|
1126
|
+
constructor() {
|
|
1127
|
+
this.wireDispatcher();
|
|
1128
|
+
}
|
|
1129
|
+
wireDispatcher() {
|
|
1130
|
+
this.jobProvider.queueDispatch = (jobName, executionId) => this.push(jobName, executionId);
|
|
1131
|
+
}
|
|
1544
1132
|
async push(jobName, executionId) {
|
|
1545
1133
|
await this.queue.push({
|
|
1546
1134
|
jobName,
|
|
@@ -1550,17 +1138,17 @@ var JobQueueProvider = class {
|
|
|
1550
1138
|
};
|
|
1551
1139
|
//#endregion
|
|
1552
1140
|
//#region ../../src/api/jobs/index.ts
|
|
1553
|
-
const jobEnvSchema = t.object({ ALEPHA_JOBS_QUEUE: t.optional(t.integer({ description: "Set to 1 to always use queue, 0 to disable queue (default: auto-detect based on environment)" })) });
|
|
1554
1141
|
/**
|
|
1555
|
-
* Job execution framework —
|
|
1142
|
+
* Job execution framework — cron and durable queue work with a single primitive.
|
|
1556
1143
|
*
|
|
1557
|
-
* **
|
|
1558
|
-
*
|
|
1559
|
-
*
|
|
1560
|
-
*
|
|
1561
|
-
* -
|
|
1562
|
-
*
|
|
1563
|
-
*
|
|
1144
|
+
* A `$job` is either **cron-only** (declares `cron`) or **queue-only** (declares `schema`).
|
|
1145
|
+
* Cron jobs run inline on their schedule and only record errors by default.
|
|
1146
|
+
* Queue jobs use the outbox pattern: push commits to DB first, then notifies via queue.
|
|
1147
|
+
*
|
|
1148
|
+
* **This module provides cron support only.** To enable queue-mode jobs, also
|
|
1149
|
+
* import {@link AlephaApiJobsQueue} — it brings in the queue layer and infrastructure
|
|
1150
|
+
* binding (e.g. Cloudflare Queues). Cron-only deployments (Vercel, CF-without-Queues)
|
|
1151
|
+
* do not need `AlephaApiJobsQueue`.
|
|
1564
1152
|
*
|
|
1565
1153
|
* @module alepha.api.jobs
|
|
1566
1154
|
*/
|
|
@@ -1571,17 +1159,22 @@ const AlephaApiJobs = $module({
|
|
|
1571
1159
|
JobProvider,
|
|
1572
1160
|
JobService,
|
|
1573
1161
|
AdminJobController
|
|
1574
|
-
]
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1162
|
+
]
|
|
1163
|
+
});
|
|
1164
|
+
/**
|
|
1165
|
+
* Queue support for `$job`. Import alongside {@link AlephaApiJobs} when your
|
|
1166
|
+
* app declares queue-mode jobs (any `$job` with a `schema`).
|
|
1167
|
+
*
|
|
1168
|
+
* Adds `JobQueueProvider` which plumbs the outbox dispatch through `AlephaQueue`.
|
|
1169
|
+
*
|
|
1170
|
+
* @module alepha.api.jobs.queue
|
|
1171
|
+
*/
|
|
1172
|
+
const AlephaApiJobsQueue = $module({
|
|
1173
|
+
name: "alepha.api.jobs.queue",
|
|
1174
|
+
imports: [AlephaApiJobs, AlephaQueue],
|
|
1175
|
+
services: [JobQueueProvider]
|
|
1583
1176
|
});
|
|
1584
1177
|
//#endregion
|
|
1585
|
-
export { $job, AdminJobController, AlephaApiJobs, JobPrimitive, JobProvider, JobQueueProvider, JobService,
|
|
1178
|
+
export { $job, AdminJobController, AlephaApiJobs, AlephaApiJobsQueue, JobPrimitive, JobProvider, JobQueueProvider, JobService, PRIORITY_MAP, PRIORITY_REVERSE, jobConfig, jobExecutionEntity, jobExecutionQuerySchema, jobExecutionResourceSchema, jobRegistrationSchema, triggerJobSchema };
|
|
1586
1179
|
|
|
1587
1180
|
//# sourceMappingURL=index.js.map
|