duron 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/adapters/postgres/base.d.ts +52 -0
- package/dist/adapters/postgres/base.d.ts.map +1 -0
- package/dist/adapters/postgres/base.js +832 -0
- package/dist/adapters/postgres/pglite.d.ts +10 -5
- package/dist/adapters/postgres/pglite.d.ts.map +1 -1
- package/dist/adapters/postgres/pglite.js +19 -7
- package/dist/adapters/postgres/postgres.d.ts +6 -39
- package/dist/adapters/postgres/postgres.d.ts.map +1 -1
- package/dist/adapters/postgres/postgres.js +9 -822
- package/dist/adapters/postgres/schema.d.ts +89 -135
- package/dist/adapters/postgres/schema.d.ts.map +1 -1
- package/dist/adapters/postgres/schema.default.d.ts +89 -135
- package/dist/adapters/postgres/schema.default.d.ts.map +1 -1
- package/migrations/postgres/20251203223656_conscious_johnny_blaze/migration.sql +64 -0
- package/migrations/postgres/20251203223656_conscious_johnny_blaze/snapshot.json +954 -0
- package/package.json +3 -3
- package/src/adapters/postgres/base.ts +1297 -0
- package/src/adapters/postgres/pglite.ts +36 -18
- package/src/adapters/postgres/postgres.ts +19 -1244
- package/migrations/postgres/0000_lethal_speed_demon.sql +0 -64
- package/migrations/postgres/meta/0000_snapshot.json +0 -606
- package/migrations/postgres/meta/_journal.json +0 -13
|
@@ -0,0 +1,1297 @@
|
|
|
1
|
+
import { and, asc, between, desc, eq, gt, gte, ilike, inArray, isNull, ne, or, sql } from 'drizzle-orm'
|
|
2
|
+
import type { PgColumn, PgDatabase } from 'drizzle-orm/pg-core'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
JOB_STATUS_ACTIVE,
|
|
6
|
+
JOB_STATUS_CANCELLED,
|
|
7
|
+
JOB_STATUS_COMPLETED,
|
|
8
|
+
JOB_STATUS_CREATED,
|
|
9
|
+
JOB_STATUS_FAILED,
|
|
10
|
+
STEP_STATUS_ACTIVE,
|
|
11
|
+
STEP_STATUS_CANCELLED,
|
|
12
|
+
STEP_STATUS_COMPLETED,
|
|
13
|
+
STEP_STATUS_FAILED,
|
|
14
|
+
} from '../../constants.js'
|
|
15
|
+
import {
|
|
16
|
+
Adapter,
|
|
17
|
+
type CancelJobOptions,
|
|
18
|
+
type CancelJobStepOptions,
|
|
19
|
+
type CompleteJobOptions,
|
|
20
|
+
type CompleteJobStepOptions,
|
|
21
|
+
type CreateJobOptions,
|
|
22
|
+
type CreateOrRecoverJobStepOptions,
|
|
23
|
+
type CreateOrRecoverJobStepResult,
|
|
24
|
+
type DelayJobStepOptions,
|
|
25
|
+
type DeleteJobOptions,
|
|
26
|
+
type DeleteJobsOptions,
|
|
27
|
+
type FailJobOptions,
|
|
28
|
+
type FailJobStepOptions,
|
|
29
|
+
type FetchOptions,
|
|
30
|
+
type GetActionsResult,
|
|
31
|
+
type GetJobStepsOptions,
|
|
32
|
+
type GetJobStepsResult,
|
|
33
|
+
type GetJobsOptions,
|
|
34
|
+
type GetJobsResult,
|
|
35
|
+
type Job,
|
|
36
|
+
type JobSort,
|
|
37
|
+
type JobStatusResult,
|
|
38
|
+
type JobStep,
|
|
39
|
+
type JobStepStatusResult,
|
|
40
|
+
type RecoverJobsOptions,
|
|
41
|
+
type RetryJobOptions,
|
|
42
|
+
} from '../adapter.js'
|
|
43
|
+
import createSchema from './schema.js'
|
|
44
|
+
|
|
45
|
+
type Schema = ReturnType<typeof createSchema>
|
|
46
|
+
|
|
47
|
+
// Re-export types for backward compatibility
|
|
48
|
+
export type { Job, JobStep } from '../adapter.js'
|
|
49
|
+
|
|
50
|
+
type DrizzleDatabase = PgDatabase<any, Schema>
|
|
51
|
+
|
|
52
|
+
export interface AdapterOptions<Connection> {
|
|
53
|
+
connection: Connection
|
|
54
|
+
schema?: string
|
|
55
|
+
migrateOnStart?: boolean
|
|
56
|
+
migrationsFolder?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> extends Adapter {
|
|
60
|
+
protected connection: Connection
|
|
61
|
+
protected db!: Database
|
|
62
|
+
protected tables: Schema
|
|
63
|
+
protected schema: string = 'duron'
|
|
64
|
+
protected migrateOnStart: boolean = true
|
|
65
|
+
|
|
66
|
+
// ============================================================================
|
|
67
|
+
// Constructor
|
|
68
|
+
// ============================================================================
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create a new PostgresAdapter instance.
|
|
72
|
+
*
|
|
73
|
+
* @param options - Configuration options for the PostgreSQL adapter
|
|
74
|
+
*/
|
|
75
|
+
constructor(options: AdapterOptions<Connection>) {
|
|
76
|
+
super()
|
|
77
|
+
|
|
78
|
+
this.connection = options.connection
|
|
79
|
+
this.schema = options.schema ?? 'duron'
|
|
80
|
+
this.migrateOnStart = options.migrateOnStart ?? true
|
|
81
|
+
|
|
82
|
+
this.tables = createSchema(this.schema)
|
|
83
|
+
|
|
84
|
+
this._initDb()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Initialize the database connection and Drizzle instance.
|
|
89
|
+
*/
|
|
90
|
+
protected _initDb() {
|
|
91
|
+
throw new Error('Not implemented')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Lifecycle Methods
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Start the adapter.
|
|
100
|
+
* Runs migrations if enabled and sets up database listeners.
|
|
101
|
+
*
|
|
102
|
+
* @returns Promise resolving to `true` if started successfully, `false` otherwise
|
|
103
|
+
*/
|
|
104
|
+
protected async _start() {
|
|
105
|
+
await this._listen(`ping-${this.id}`, async (payload: string) => {
|
|
106
|
+
const fromOwnerId = JSON.parse(payload).fromOwnerId
|
|
107
|
+
await this._notify(`pong-${fromOwnerId}`, { toOwnerId: this.id })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
await this._listen(`job-status-changed`, (payload: string) => {
|
|
111
|
+
if (this.listenerCount('job-status-changed') > 0) {
|
|
112
|
+
const { jobId, status, ownerId } = JSON.parse(payload)
|
|
113
|
+
this.emit('job-status-changed', { jobId, status, ownerId })
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
await this._listen(`job-available`, (payload: string) => {
|
|
118
|
+
if (this.listenerCount('job-available') > 0) {
|
|
119
|
+
const { jobId } = JSON.parse(payload)
|
|
120
|
+
this.emit('job-available', { jobId })
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
protected async _stop() {
|
|
126
|
+
// do nothing
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// Job Methods
|
|
131
|
+
// ============================================================================
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Internal method to create a new job in the database.
|
|
135
|
+
*
|
|
136
|
+
* @returns Promise resolving to the job ID, or `null` if creation failed
|
|
137
|
+
*/
|
|
138
|
+
protected async _createJob({ queue, groupKey, input, timeoutMs, checksum, concurrencyLimit }: CreateJobOptions) {
|
|
139
|
+
const [result] = await this.db
|
|
140
|
+
.insert(this.tables.jobsTable)
|
|
141
|
+
.values({
|
|
142
|
+
action_name: queue,
|
|
143
|
+
group_key: groupKey,
|
|
144
|
+
checksum,
|
|
145
|
+
input,
|
|
146
|
+
status: JOB_STATUS_CREATED,
|
|
147
|
+
timeout_ms: timeoutMs,
|
|
148
|
+
concurrency_limit: concurrencyLimit,
|
|
149
|
+
})
|
|
150
|
+
.returning({ id: this.tables.jobsTable.id })
|
|
151
|
+
|
|
152
|
+
if (!result) {
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return result.id
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Internal method to mark a job as completed.
|
|
161
|
+
*
|
|
162
|
+
* @returns Promise resolving to `true` if completed, `false` otherwise
|
|
163
|
+
*/
|
|
164
|
+
protected async _completeJob({ jobId, output }: CompleteJobOptions) {
|
|
165
|
+
const result = await this.db
|
|
166
|
+
.update(this.tables.jobsTable)
|
|
167
|
+
.set({
|
|
168
|
+
status: JOB_STATUS_COMPLETED,
|
|
169
|
+
output,
|
|
170
|
+
finished_at: sql`now()`,
|
|
171
|
+
})
|
|
172
|
+
.where(
|
|
173
|
+
and(
|
|
174
|
+
eq(this.tables.jobsTable.id, jobId),
|
|
175
|
+
eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE),
|
|
176
|
+
eq(this.tables.jobsTable.owner_id, this.id),
|
|
177
|
+
gt(this.tables.jobsTable.expires_at, sql`now()`),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
.returning({ id: this.tables.jobsTable.id })
|
|
181
|
+
|
|
182
|
+
return result.length > 0
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Internal method to mark a job as failed.
|
|
187
|
+
*
|
|
188
|
+
* @returns Promise resolving to `true` if failed, `false` otherwise
|
|
189
|
+
*/
|
|
190
|
+
protected async _failJob({ jobId, error }: FailJobOptions) {
|
|
191
|
+
const result = await this.db
|
|
192
|
+
.update(this.tables.jobsTable)
|
|
193
|
+
.set({
|
|
194
|
+
status: JOB_STATUS_FAILED,
|
|
195
|
+
error,
|
|
196
|
+
finished_at: sql`now()`,
|
|
197
|
+
})
|
|
198
|
+
.where(
|
|
199
|
+
and(
|
|
200
|
+
eq(this.tables.jobsTable.id, jobId),
|
|
201
|
+
eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE),
|
|
202
|
+
eq(this.tables.jobsTable.owner_id, this.id),
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
.returning({ id: this.tables.jobsTable.id })
|
|
206
|
+
|
|
207
|
+
return result.length > 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Internal method to cancel a job.
|
|
212
|
+
*
|
|
213
|
+
* @returns Promise resolving to `true` if cancelled, `false` otherwise
|
|
214
|
+
*/
|
|
215
|
+
protected async _cancelJob({ jobId }: CancelJobOptions) {
|
|
216
|
+
const result = await this.db
|
|
217
|
+
.update(this.tables.jobsTable)
|
|
218
|
+
.set({
|
|
219
|
+
status: JOB_STATUS_CANCELLED,
|
|
220
|
+
finished_at: sql`now()`,
|
|
221
|
+
})
|
|
222
|
+
.where(
|
|
223
|
+
and(
|
|
224
|
+
eq(this.tables.jobsTable.id, jobId),
|
|
225
|
+
or(eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE), eq(this.tables.jobsTable.status, JOB_STATUS_CREATED)),
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
.returning({ id: this.tables.jobsTable.id })
|
|
229
|
+
|
|
230
|
+
return result.length > 0
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Internal method to retry a completed, cancelled, or failed job by creating a copy of it with status 'created' and cleared output/error.
|
|
235
|
+
* Uses SELECT FOR UPDATE to prevent concurrent retries from creating duplicate jobs.
|
|
236
|
+
*
|
|
237
|
+
* @returns Promise resolving to the job ID, or `null` if creation failed
|
|
238
|
+
*/
|
|
239
|
+
protected async _retryJob({ jobId }: RetryJobOptions): Promise<string | null> {
|
|
240
|
+
// Use a single atomic query with FOR UPDATE lock to prevent race conditions
|
|
241
|
+
const result = this._map(
|
|
242
|
+
await this.db.execute<{ id: string }>(sql`
|
|
243
|
+
WITH locked_source AS (
|
|
244
|
+
-- Lock the source job row to prevent concurrent retries
|
|
245
|
+
SELECT
|
|
246
|
+
j.action_name,
|
|
247
|
+
j.group_key,
|
|
248
|
+
j.checksum,
|
|
249
|
+
j.input,
|
|
250
|
+
j.timeout_ms,
|
|
251
|
+
j.created_at,
|
|
252
|
+
j.concurrency_limit
|
|
253
|
+
FROM ${this.tables.jobsTable} j
|
|
254
|
+
WHERE j.id = ${jobId}
|
|
255
|
+
AND j.status IN (${JOB_STATUS_COMPLETED}, ${JOB_STATUS_CANCELLED}, ${JOB_STATUS_FAILED})
|
|
256
|
+
FOR UPDATE OF j SKIP LOCKED
|
|
257
|
+
),
|
|
258
|
+
existing_retry AS (
|
|
259
|
+
-- Check if a retry already exists (a newer job with same checksum, group_key, and input)
|
|
260
|
+
SELECT j.id
|
|
261
|
+
FROM ${this.tables.jobsTable} j
|
|
262
|
+
INNER JOIN locked_source ls
|
|
263
|
+
ON j.action_name = ls.action_name
|
|
264
|
+
AND j.group_key = ls.group_key
|
|
265
|
+
AND j.checksum = ls.checksum
|
|
266
|
+
AND j.input = ls.input
|
|
267
|
+
AND j.created_at > ls.created_at
|
|
268
|
+
WHERE j.status IN (${JOB_STATUS_CREATED}, ${JOB_STATUS_ACTIVE})
|
|
269
|
+
LIMIT 1
|
|
270
|
+
),
|
|
271
|
+
inserted_retry AS (
|
|
272
|
+
-- Insert the retry only if no existing retry was found
|
|
273
|
+
-- Get concurrency_limit from the latest job at insertion time to avoid stale values
|
|
274
|
+
INSERT INTO ${this.tables.jobsTable} (
|
|
275
|
+
action_name,
|
|
276
|
+
group_key,
|
|
277
|
+
checksum,
|
|
278
|
+
input,
|
|
279
|
+
status,
|
|
280
|
+
timeout_ms,
|
|
281
|
+
concurrency_limit
|
|
282
|
+
)
|
|
283
|
+
SELECT
|
|
284
|
+
ls.action_name,
|
|
285
|
+
ls.group_key,
|
|
286
|
+
ls.checksum,
|
|
287
|
+
ls.input,
|
|
288
|
+
${JOB_STATUS_CREATED},
|
|
289
|
+
ls.timeout_ms,
|
|
290
|
+
COALESCE(
|
|
291
|
+
(
|
|
292
|
+
SELECT j.concurrency_limit
|
|
293
|
+
FROM ${this.tables.jobsTable} j
|
|
294
|
+
WHERE j.action_name = ls.action_name
|
|
295
|
+
AND j.group_key = ls.group_key
|
|
296
|
+
AND (j.expires_at IS NULL OR j.expires_at > now())
|
|
297
|
+
ORDER BY j.created_at DESC, j.id DESC
|
|
298
|
+
LIMIT 1
|
|
299
|
+
),
|
|
300
|
+
ls.concurrency_limit
|
|
301
|
+
)
|
|
302
|
+
FROM locked_source ls
|
|
303
|
+
WHERE NOT EXISTS (SELECT 1 FROM existing_retry)
|
|
304
|
+
RETURNING id
|
|
305
|
+
)
|
|
306
|
+
-- Return only the newly inserted retry ID (not existing retries)
|
|
307
|
+
SELECT id FROM inserted_retry
|
|
308
|
+
LIMIT 1
|
|
309
|
+
`),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if (result.length === 0) {
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return result[0]!.id
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Internal method to delete a job by its ID.
|
|
321
|
+
* Active jobs cannot be deleted.
|
|
322
|
+
*
|
|
323
|
+
* @returns Promise resolving to `true` if deleted, `false` otherwise
|
|
324
|
+
*/
|
|
325
|
+
protected async _deleteJob({ jobId }: DeleteJobOptions): Promise<boolean> {
|
|
326
|
+
const result = await this.db
|
|
327
|
+
.delete(this.tables.jobsTable)
|
|
328
|
+
.where(and(eq(this.tables.jobsTable.id, jobId), ne(this.tables.jobsTable.status, JOB_STATUS_ACTIVE)))
|
|
329
|
+
.returning({ id: this.tables.jobsTable.id })
|
|
330
|
+
|
|
331
|
+
// Also delete associated steps
|
|
332
|
+
if (result.length > 0) {
|
|
333
|
+
await this.db.delete(this.tables.jobStepsTable).where(eq(this.tables.jobStepsTable.job_id, jobId))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return result.length > 0
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Internal method to delete multiple jobs using the same filters as getJobs.
|
|
341
|
+
* Active jobs cannot be deleted and will be excluded from deletion.
|
|
342
|
+
*
|
|
343
|
+
* @returns Promise resolving to the number of jobs deleted
|
|
344
|
+
*/
|
|
345
|
+
protected async _deleteJobs(options?: DeleteJobsOptions): Promise<number> {
|
|
346
|
+
const jobsTable = this.tables.jobsTable
|
|
347
|
+
const filters = options?.filters ?? {}
|
|
348
|
+
|
|
349
|
+
const where = this._buildJobsWhereClause(filters)
|
|
350
|
+
|
|
351
|
+
const result = await this.db.delete(jobsTable).where(where).returning({ id: jobsTable.id })
|
|
352
|
+
|
|
353
|
+
return result.length
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Internal method to fetch jobs from the database respecting concurrency limits per group.
|
|
358
|
+
* Uses the concurrency limit from the latest job created for each groupKey.
|
|
359
|
+
* Uses advisory locks to ensure thread-safe job fetching.
|
|
360
|
+
*
|
|
361
|
+
* @returns Promise resolving to an array of fetched jobs
|
|
362
|
+
*/
|
|
363
|
+
protected async _fetch({ batch }: FetchOptions) {
|
|
364
|
+
const result = this._map(
|
|
365
|
+
await this.db.execute<Job>(sql`
|
|
366
|
+
WITH group_concurrency AS (
|
|
367
|
+
-- Get the concurrency limit from the latest job for each group
|
|
368
|
+
SELECT DISTINCT ON (j.group_key, j.action_name)
|
|
369
|
+
j.group_key as group_key,
|
|
370
|
+
j.action_name as action_name,
|
|
371
|
+
j.concurrency_limit as concurrency_limit
|
|
372
|
+
FROM ${this.tables.jobsTable} j
|
|
373
|
+
WHERE j.group_key IS NOT NULL
|
|
374
|
+
AND (j.expires_at IS NULL OR j.expires_at > now())
|
|
375
|
+
ORDER BY j.group_key, j.action_name, j.created_at DESC, j.id DESC
|
|
376
|
+
),
|
|
377
|
+
eligible_groups AS (
|
|
378
|
+
-- Find all groups with their active counts that are below their concurrency limit
|
|
379
|
+
SELECT
|
|
380
|
+
gc.group_key,
|
|
381
|
+
gc.action_name,
|
|
382
|
+
gc.concurrency_limit,
|
|
383
|
+
COUNT(*) FILTER (WHERE j.status = ${JOB_STATUS_ACTIVE}) as active_count
|
|
384
|
+
FROM group_concurrency gc
|
|
385
|
+
LEFT JOIN ${this.tables.jobsTable} j
|
|
386
|
+
ON j.group_key = gc.group_key
|
|
387
|
+
AND j.action_name = gc.action_name
|
|
388
|
+
AND (j.expires_at IS NULL OR j.expires_at > now())
|
|
389
|
+
GROUP BY gc.group_key, gc.action_name, gc.concurrency_limit
|
|
390
|
+
HAVING COUNT(*) FILTER (WHERE j.status = ${JOB_STATUS_ACTIVE}) < gc.concurrency_limit
|
|
391
|
+
),
|
|
392
|
+
candidate_jobs AS (
|
|
393
|
+
-- Lock candidate jobs first (before applying window functions)
|
|
394
|
+
SELECT
|
|
395
|
+
j.id,
|
|
396
|
+
j.action_name,
|
|
397
|
+
j.group_key as job_group_key,
|
|
398
|
+
j.created_at
|
|
399
|
+
FROM ${this.tables.jobsTable} j
|
|
400
|
+
INNER JOIN eligible_groups eg
|
|
401
|
+
ON j.group_key = eg.group_key
|
|
402
|
+
AND j.action_name = eg.action_name
|
|
403
|
+
WHERE j.status = ${JOB_STATUS_CREATED}
|
|
404
|
+
FOR UPDATE OF j SKIP LOCKED
|
|
405
|
+
),
|
|
406
|
+
ranked_jobs AS (
|
|
407
|
+
-- Rank jobs within each group after locking
|
|
408
|
+
SELECT
|
|
409
|
+
cj.id,
|
|
410
|
+
cj.action_name,
|
|
411
|
+
cj.job_group_key,
|
|
412
|
+
cj.created_at,
|
|
413
|
+
ROW_NUMBER() OVER (
|
|
414
|
+
PARTITION BY cj.job_group_key, cj.action_name
|
|
415
|
+
ORDER BY cj.created_at ASC, cj.id ASC
|
|
416
|
+
) as job_rank
|
|
417
|
+
FROM candidate_jobs cj
|
|
418
|
+
),
|
|
419
|
+
next_job AS (
|
|
420
|
+
-- Select only jobs that fit within the concurrency limit per group
|
|
421
|
+
-- Ordered globally by created_at to respect job creation order
|
|
422
|
+
SELECT rj.id, rj.action_name, rj.job_group_key
|
|
423
|
+
FROM ranked_jobs rj
|
|
424
|
+
INNER JOIN eligible_groups eg
|
|
425
|
+
ON rj.job_group_key = eg.group_key
|
|
426
|
+
AND rj.action_name = eg.action_name
|
|
427
|
+
WHERE rj.job_rank <= (eg.concurrency_limit - eg.active_count)
|
|
428
|
+
ORDER BY rj.created_at ASC, rj.id ASC
|
|
429
|
+
LIMIT ${batch}
|
|
430
|
+
),
|
|
431
|
+
verify_concurrency AS (
|
|
432
|
+
-- Double-check concurrency limit after acquiring lock
|
|
433
|
+
SELECT
|
|
434
|
+
nj.id,
|
|
435
|
+
nj.action_name,
|
|
436
|
+
nj.job_group_key,
|
|
437
|
+
eg.concurrency_limit,
|
|
438
|
+
(SELECT COUNT(*)
|
|
439
|
+
FROM ${this.tables.jobsTable}
|
|
440
|
+
WHERE action_name = nj.action_name
|
|
441
|
+
AND group_key = nj.job_group_key
|
|
442
|
+
AND status = ${JOB_STATUS_ACTIVE}) as current_active
|
|
443
|
+
FROM next_job nj
|
|
444
|
+
INNER JOIN eligible_groups eg
|
|
445
|
+
ON nj.job_group_key = eg.group_key
|
|
446
|
+
AND nj.action_name = eg.action_name
|
|
447
|
+
)
|
|
448
|
+
UPDATE ${this.tables.jobsTable} j
|
|
449
|
+
SET status = ${JOB_STATUS_ACTIVE},
|
|
450
|
+
started_at = now(),
|
|
451
|
+
expires_at = now() + (timeout_ms || ' milliseconds')::interval,
|
|
452
|
+
owner_id = ${this.id}
|
|
453
|
+
FROM verify_concurrency vc
|
|
454
|
+
WHERE j.id = vc.id
|
|
455
|
+
AND vc.current_active < vc.concurrency_limit -- Final concurrency check using job's concurrency limit
|
|
456
|
+
RETURNING
|
|
457
|
+
j.id,
|
|
458
|
+
j.action_name as "actionName",
|
|
459
|
+
j.group_key as "groupKey",
|
|
460
|
+
j.input,
|
|
461
|
+
j.output,
|
|
462
|
+
j.error,
|
|
463
|
+
j.status,
|
|
464
|
+
j.timeout_ms as "timeoutMs",
|
|
465
|
+
j.expires_at as "expiresAt",
|
|
466
|
+
j.started_at as "startedAt",
|
|
467
|
+
j.finished_at as "finishedAt",
|
|
468
|
+
j.created_at as "createdAt",
|
|
469
|
+
j.updated_at as "updatedAt",
|
|
470
|
+
j.concurrency_limit as "concurrencyLimit"
|
|
471
|
+
`),
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
return result
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Internal method to recover stuck jobs (jobs that were active but the process that owned them is no longer running).
|
|
479
|
+
* In multi-process mode, pings other processes to check if they're alive before recovering their jobs.
|
|
480
|
+
*
|
|
481
|
+
* @returns Promise resolving to the number of jobs recovered
|
|
482
|
+
*/
|
|
483
|
+
protected async _recoverJobs(options: RecoverJobsOptions): Promise<number> {
|
|
484
|
+
const { checksums, multiProcessMode = false, processTimeout = 5_000 } = options
|
|
485
|
+
|
|
486
|
+
const unresponsiveOwnerIds: string[] = [this.id]
|
|
487
|
+
|
|
488
|
+
if (multiProcessMode) {
|
|
489
|
+
const result = (await this.db
|
|
490
|
+
.selectDistinct({
|
|
491
|
+
ownerId: this.tables.jobsTable.owner_id,
|
|
492
|
+
})
|
|
493
|
+
.from(this.tables.jobsTable)
|
|
494
|
+
.where(
|
|
495
|
+
and(eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE), ne(this.tables.jobsTable.owner_id, this.id)),
|
|
496
|
+
)) as unknown as { ownerId: string }[]
|
|
497
|
+
|
|
498
|
+
if (result.length > 0) {
|
|
499
|
+
const pongCount = new Set<string>()
|
|
500
|
+
const { unlisten } = await this._listen(`pong-${this.id}`, (payload: string) => {
|
|
501
|
+
const toOwnerId = JSON.parse(payload).toOwnerId
|
|
502
|
+
pongCount.add(toOwnerId)
|
|
503
|
+
if (pongCount.size >= result.length) {
|
|
504
|
+
unlisten()
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
await Promise.all(result.map((row) => this._notify(`ping-${row.ownerId}`, { fromOwnerId: this.id })))
|
|
509
|
+
|
|
510
|
+
let waitForSeconds = processTimeout / 1_000
|
|
511
|
+
while (pongCount.size < result.length && waitForSeconds > 0) {
|
|
512
|
+
await new Promise((resolve) => setTimeout(resolve, 1000).unref?.())
|
|
513
|
+
waitForSeconds--
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
unresponsiveOwnerIds.push(...result.filter((row) => !pongCount.has(row.ownerId)).map((row) => row.ownerId))
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (unresponsiveOwnerIds.length > 0) {
|
|
521
|
+
const result = this._map(
|
|
522
|
+
await this.db.execute<{ id: string }>(sql`
|
|
523
|
+
WITH locked_jobs AS (
|
|
524
|
+
SELECT j.id
|
|
525
|
+
FROM ${this.tables.jobsTable} j
|
|
526
|
+
WHERE j.status = ${JOB_STATUS_ACTIVE}
|
|
527
|
+
AND j.owner_id IN ${unresponsiveOwnerIds}
|
|
528
|
+
FOR UPDATE OF j SKIP LOCKED
|
|
529
|
+
),
|
|
530
|
+
updated_jobs AS (
|
|
531
|
+
UPDATE ${this.tables.jobsTable} j
|
|
532
|
+
SET status = ${JOB_STATUS_CREATED},
|
|
533
|
+
started_at = NULL,
|
|
534
|
+
expires_at = NULL,
|
|
535
|
+
finished_at = NULL,
|
|
536
|
+
output = NULL,
|
|
537
|
+
error = NULL
|
|
538
|
+
WHERE EXISTS (SELECT 1 FROM locked_jobs lj WHERE lj.id = j.id)
|
|
539
|
+
RETURNING id, checksum
|
|
540
|
+
),
|
|
541
|
+
deleted_steps AS (
|
|
542
|
+
DELETE FROM ${this.tables.jobStepsTable} s
|
|
543
|
+
WHERE EXISTS (
|
|
544
|
+
SELECT 1 FROM updated_jobs uj
|
|
545
|
+
WHERE uj.id = s.job_id
|
|
546
|
+
AND uj.checksum NOT IN ${checksums}
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
SELECT id FROM updated_jobs
|
|
550
|
+
`),
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
return result.length
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return 0
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ============================================================================
|
|
560
|
+
// Step Methods
|
|
561
|
+
// ============================================================================
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Internal method to create or recover a job step by creating or resetting a step record in the database.
|
|
565
|
+
*
|
|
566
|
+
* @returns Promise resolving to the step, or `null` if creation failed
|
|
567
|
+
*/
|
|
568
|
+
protected async _createOrRecoverJobStep({
|
|
569
|
+
jobId,
|
|
570
|
+
name,
|
|
571
|
+
timeoutMs,
|
|
572
|
+
retriesLimit,
|
|
573
|
+
}: CreateOrRecoverJobStepOptions): Promise<CreateOrRecoverJobStepResult | null> {
|
|
574
|
+
type StepResult = CreateOrRecoverJobStepResult
|
|
575
|
+
|
|
576
|
+
const [result] = this._map(
|
|
577
|
+
await this.db.execute<StepResult>(sql`
|
|
578
|
+
WITH job_check AS (
|
|
579
|
+
SELECT j.id
|
|
580
|
+
FROM ${this.tables.jobsTable} j
|
|
581
|
+
WHERE j.id = ${jobId}
|
|
582
|
+
AND j.status = ${JOB_STATUS_ACTIVE}
|
|
583
|
+
AND (j.expires_at IS NULL OR j.expires_at > now())
|
|
584
|
+
),
|
|
585
|
+
step_existed AS (
|
|
586
|
+
SELECT EXISTS(
|
|
587
|
+
SELECT 1 FROM ${this.tables.jobStepsTable} s
|
|
588
|
+
WHERE s.job_id = ${jobId} AND s.name = ${name}
|
|
589
|
+
) AS existed
|
|
590
|
+
),
|
|
591
|
+
upserted_step AS (
|
|
592
|
+
INSERT INTO ${this.tables.jobStepsTable} (
|
|
593
|
+
job_id,
|
|
594
|
+
name,
|
|
595
|
+
timeout_ms,
|
|
596
|
+
retries_limit,
|
|
597
|
+
status,
|
|
598
|
+
started_at,
|
|
599
|
+
expires_at,
|
|
600
|
+
retries_count,
|
|
601
|
+
delayed_ms
|
|
602
|
+
)
|
|
603
|
+
SELECT
|
|
604
|
+
${jobId},
|
|
605
|
+
${name},
|
|
606
|
+
${timeoutMs},
|
|
607
|
+
${retriesLimit},
|
|
608
|
+
${STEP_STATUS_ACTIVE},
|
|
609
|
+
now(),
|
|
610
|
+
now() + interval '${sql.raw(timeoutMs.toString())} milliseconds',
|
|
611
|
+
0,
|
|
612
|
+
NULL
|
|
613
|
+
WHERE EXISTS (SELECT 1 FROM job_check)
|
|
614
|
+
ON CONFLICT (job_id, name) DO UPDATE
|
|
615
|
+
SET
|
|
616
|
+
timeout_ms = ${timeoutMs},
|
|
617
|
+
expires_at = now() + interval '${sql.raw(timeoutMs.toString())} milliseconds',
|
|
618
|
+
retries_count = 0,
|
|
619
|
+
retries_limit = ${retriesLimit},
|
|
620
|
+
delayed_ms = NULL,
|
|
621
|
+
started_at = now(),
|
|
622
|
+
history_failed_attempts = '{}'::jsonb
|
|
623
|
+
WHERE ${this.tables.jobStepsTable}.status = ${STEP_STATUS_ACTIVE}
|
|
624
|
+
RETURNING
|
|
625
|
+
id,
|
|
626
|
+
status,
|
|
627
|
+
retries_limit AS "retriesLimit",
|
|
628
|
+
retries_count AS "retriesCount",
|
|
629
|
+
timeout_ms AS "timeoutMs",
|
|
630
|
+
error,
|
|
631
|
+
output
|
|
632
|
+
),
|
|
633
|
+
final_upserted AS (
|
|
634
|
+
SELECT
|
|
635
|
+
us.*,
|
|
636
|
+
CASE WHEN se.existed THEN false ELSE true END AS "isNew"
|
|
637
|
+
FROM upserted_step us
|
|
638
|
+
CROSS JOIN step_existed se
|
|
639
|
+
),
|
|
640
|
+
existing_step AS (
|
|
641
|
+
SELECT
|
|
642
|
+
s.id,
|
|
643
|
+
s.status,
|
|
644
|
+
s.retries_limit AS "retriesLimit",
|
|
645
|
+
s.retries_count AS "retriesCount",
|
|
646
|
+
s.timeout_ms AS "timeoutMs",
|
|
647
|
+
s.error,
|
|
648
|
+
s.output,
|
|
649
|
+
false AS "isNew"
|
|
650
|
+
FROM ${this.tables.jobStepsTable} s
|
|
651
|
+
INNER JOIN job_check jc ON s.job_id = jc.id
|
|
652
|
+
WHERE s.job_id = ${jobId}
|
|
653
|
+
AND s.name = ${name}
|
|
654
|
+
AND NOT EXISTS (SELECT 1 FROM final_upserted)
|
|
655
|
+
)
|
|
656
|
+
SELECT * FROM final_upserted
|
|
657
|
+
UNION ALL
|
|
658
|
+
SELECT * FROM existing_step
|
|
659
|
+
`),
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
if (!result) {
|
|
663
|
+
this.logger?.error({ jobId }, `[PostgresAdapter] Job ${jobId} is not active or has expired`)
|
|
664
|
+
return null
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return result
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Internal method to mark a job step as completed.
|
|
672
|
+
*
|
|
673
|
+
* @returns Promise resolving to `true` if completed, `false` otherwise
|
|
674
|
+
*/
|
|
675
|
+
protected async _completeJobStep({ stepId, output }: CompleteJobStepOptions) {
|
|
676
|
+
const result = await this.db
|
|
677
|
+
.update(this.tables.jobStepsTable)
|
|
678
|
+
.set({
|
|
679
|
+
status: STEP_STATUS_COMPLETED,
|
|
680
|
+
output,
|
|
681
|
+
finished_at: sql`now()`,
|
|
682
|
+
})
|
|
683
|
+
.from(this.tables.jobsTable)
|
|
684
|
+
.where(
|
|
685
|
+
and(
|
|
686
|
+
eq(this.tables.jobStepsTable.job_id, this.tables.jobsTable.id),
|
|
687
|
+
eq(this.tables.jobStepsTable.id, stepId),
|
|
688
|
+
eq(this.tables.jobStepsTable.status, STEP_STATUS_ACTIVE),
|
|
689
|
+
eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE),
|
|
690
|
+
or(isNull(this.tables.jobsTable.expires_at), gt(this.tables.jobsTable.expires_at, sql`now()`)),
|
|
691
|
+
),
|
|
692
|
+
)
|
|
693
|
+
.returning({ id: this.tables.jobStepsTable.id })
|
|
694
|
+
|
|
695
|
+
return result.length > 0
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Internal method to mark a job step as failed.
|
|
700
|
+
*
|
|
701
|
+
* @returns Promise resolving to `true` if failed, `false` otherwise
|
|
702
|
+
*/
|
|
703
|
+
protected async _failJobStep({ stepId, error }: FailJobStepOptions) {
|
|
704
|
+
const result = await this.db
|
|
705
|
+
.update(this.tables.jobStepsTable)
|
|
706
|
+
.set({
|
|
707
|
+
status: STEP_STATUS_FAILED,
|
|
708
|
+
error,
|
|
709
|
+
finished_at: sql`now()`,
|
|
710
|
+
})
|
|
711
|
+
.from(this.tables.jobsTable)
|
|
712
|
+
.where(
|
|
713
|
+
and(
|
|
714
|
+
eq(this.tables.jobStepsTable.job_id, this.tables.jobsTable.id),
|
|
715
|
+
eq(this.tables.jobStepsTable.id, stepId),
|
|
716
|
+
eq(this.tables.jobStepsTable.status, STEP_STATUS_ACTIVE),
|
|
717
|
+
eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE),
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
.returning({ id: this.tables.jobStepsTable.id })
|
|
721
|
+
|
|
722
|
+
return result.length > 0
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Internal method to delay a job step.
|
|
727
|
+
*
|
|
728
|
+
* @returns Promise resolving to `true` if delayed, `false` otherwise
|
|
729
|
+
*/
|
|
730
|
+
protected async _delayJobStep({ stepId, delayMs, error }: DelayJobStepOptions) {
|
|
731
|
+
const jobStepsTable = this.tables.jobStepsTable
|
|
732
|
+
const jobsTable = this.tables.jobsTable
|
|
733
|
+
|
|
734
|
+
const result = await this.db
|
|
735
|
+
.update(jobStepsTable)
|
|
736
|
+
.set({
|
|
737
|
+
delayed_ms: delayMs,
|
|
738
|
+
retries_count: sql`${jobStepsTable.retries_count} + 1`,
|
|
739
|
+
expires_at: sql`now() + (${jobStepsTable.timeout_ms} || ' milliseconds')::interval + (${delayMs} || ' milliseconds')::interval`,
|
|
740
|
+
history_failed_attempts: sql`COALESCE(${jobStepsTable.history_failed_attempts}, '{}'::jsonb) || jsonb_build_object(
|
|
741
|
+
extract(epoch from now())::text,
|
|
742
|
+
jsonb_build_object(
|
|
743
|
+
'failedAt', now(),
|
|
744
|
+
'error', ${JSON.stringify(error)}::jsonb,
|
|
745
|
+
'delayedMs', ${delayMs}::integer
|
|
746
|
+
)
|
|
747
|
+
)`,
|
|
748
|
+
})
|
|
749
|
+
.from(jobsTable)
|
|
750
|
+
.where(
|
|
751
|
+
and(
|
|
752
|
+
eq(jobStepsTable.job_id, jobsTable.id),
|
|
753
|
+
eq(jobStepsTable.id, stepId),
|
|
754
|
+
eq(jobStepsTable.status, STEP_STATUS_ACTIVE),
|
|
755
|
+
eq(jobsTable.status, JOB_STATUS_ACTIVE),
|
|
756
|
+
),
|
|
757
|
+
)
|
|
758
|
+
.returning({ id: jobStepsTable.id })
|
|
759
|
+
|
|
760
|
+
return result.length > 0
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Internal method to cancel a job step.
|
|
765
|
+
*
|
|
766
|
+
* @returns Promise resolving to `true` if cancelled, `false` otherwise
|
|
767
|
+
*/
|
|
768
|
+
protected async _cancelJobStep({ stepId }: CancelJobStepOptions) {
|
|
769
|
+
const result = await this.db
|
|
770
|
+
.update(this.tables.jobStepsTable)
|
|
771
|
+
.set({
|
|
772
|
+
status: STEP_STATUS_CANCELLED,
|
|
773
|
+
finished_at: sql`now()`,
|
|
774
|
+
})
|
|
775
|
+
.from(this.tables.jobsTable)
|
|
776
|
+
.where(
|
|
777
|
+
and(
|
|
778
|
+
eq(this.tables.jobStepsTable.job_id, this.tables.jobsTable.id),
|
|
779
|
+
eq(this.tables.jobStepsTable.id, stepId),
|
|
780
|
+
eq(this.tables.jobStepsTable.status, STEP_STATUS_ACTIVE),
|
|
781
|
+
or(
|
|
782
|
+
eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE),
|
|
783
|
+
eq(this.tables.jobsTable.status, JOB_STATUS_CANCELLED),
|
|
784
|
+
),
|
|
785
|
+
),
|
|
786
|
+
)
|
|
787
|
+
.returning({ id: this.tables.jobStepsTable.id })
|
|
788
|
+
|
|
789
|
+
return result.length > 0
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ============================================================================
|
|
793
|
+
// Query Methods
|
|
794
|
+
// ============================================================================
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Internal method to get a job by its ID. Does not include step information.
|
|
798
|
+
*/
|
|
799
|
+
protected async _getJobById(jobId: string): Promise<Job | null> {
|
|
800
|
+
const [job] = await this.db
|
|
801
|
+
.select({
|
|
802
|
+
id: this.tables.jobsTable.id,
|
|
803
|
+
actionName: this.tables.jobsTable.action_name,
|
|
804
|
+
groupKey: this.tables.jobsTable.group_key,
|
|
805
|
+
input: this.tables.jobsTable.input,
|
|
806
|
+
output: this.tables.jobsTable.output,
|
|
807
|
+
error: this.tables.jobsTable.error,
|
|
808
|
+
status: this.tables.jobsTable.status,
|
|
809
|
+
timeoutMs: this.tables.jobsTable.timeout_ms,
|
|
810
|
+
expiresAt: this.tables.jobsTable.expires_at,
|
|
811
|
+
startedAt: this.tables.jobsTable.started_at,
|
|
812
|
+
finishedAt: this.tables.jobsTable.finished_at,
|
|
813
|
+
createdAt: this.tables.jobsTable.created_at,
|
|
814
|
+
updatedAt: this.tables.jobsTable.updated_at,
|
|
815
|
+
concurrencyLimit: this.tables.jobsTable.concurrency_limit,
|
|
816
|
+
})
|
|
817
|
+
.from(this.tables.jobsTable)
|
|
818
|
+
.where(eq(this.tables.jobsTable.id, jobId))
|
|
819
|
+
.limit(1)
|
|
820
|
+
|
|
821
|
+
return job ?? null
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Internal method to get steps for a job with pagination and fuzzy search.
|
|
826
|
+
* Steps are always ordered by created_at ASC.
|
|
827
|
+
* Steps do not include output data.
|
|
828
|
+
*/
|
|
829
|
+
protected async _getJobSteps(options: GetJobStepsOptions): Promise<GetJobStepsResult> {
|
|
830
|
+
const { jobId, page = 1, pageSize = 10, search } = options
|
|
831
|
+
|
|
832
|
+
const jobStepsTable = this.tables.jobStepsTable
|
|
833
|
+
|
|
834
|
+
const fuzzySearch = search?.trim()
|
|
835
|
+
|
|
836
|
+
const where = and(
|
|
837
|
+
eq(jobStepsTable.job_id, jobId),
|
|
838
|
+
fuzzySearch && fuzzySearch.length > 0
|
|
839
|
+
? or(
|
|
840
|
+
ilike(jobStepsTable.name, `%${fuzzySearch}%`),
|
|
841
|
+
sql`to_tsvector('english', ${jobStepsTable.output}::text) @@ plainto_tsquery('english', ${fuzzySearch})`,
|
|
842
|
+
)
|
|
843
|
+
: undefined,
|
|
844
|
+
options.updatedAfter
|
|
845
|
+
? sql`date_trunc('milliseconds', ${jobStepsTable.updated_at}) > ${options.updatedAfter.toISOString()}::timestamptz`
|
|
846
|
+
: undefined,
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
// Get total count
|
|
850
|
+
const total = await this.db.$count(jobStepsTable, where)
|
|
851
|
+
|
|
852
|
+
if (!total) {
|
|
853
|
+
return {
|
|
854
|
+
steps: [],
|
|
855
|
+
total: 0,
|
|
856
|
+
page,
|
|
857
|
+
pageSize,
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const steps = await this.db
|
|
862
|
+
.select({
|
|
863
|
+
id: jobStepsTable.id,
|
|
864
|
+
jobId: jobStepsTable.job_id,
|
|
865
|
+
name: jobStepsTable.name,
|
|
866
|
+
status: jobStepsTable.status,
|
|
867
|
+
error: jobStepsTable.error,
|
|
868
|
+
startedAt: jobStepsTable.started_at,
|
|
869
|
+
finishedAt: jobStepsTable.finished_at,
|
|
870
|
+
timeoutMs: jobStepsTable.timeout_ms,
|
|
871
|
+
expiresAt: jobStepsTable.expires_at,
|
|
872
|
+
retriesLimit: jobStepsTable.retries_limit,
|
|
873
|
+
retriesCount: jobStepsTable.retries_count,
|
|
874
|
+
delayedMs: jobStepsTable.delayed_ms,
|
|
875
|
+
historyFailedAttempts: jobStepsTable.history_failed_attempts,
|
|
876
|
+
createdAt: jobStepsTable.created_at,
|
|
877
|
+
updatedAt: jobStepsTable.updated_at,
|
|
878
|
+
})
|
|
879
|
+
.from(jobStepsTable)
|
|
880
|
+
.where(where)
|
|
881
|
+
.orderBy(asc(jobStepsTable.created_at))
|
|
882
|
+
.limit(pageSize)
|
|
883
|
+
.offset((page - 1) * pageSize)
|
|
884
|
+
|
|
885
|
+
return {
|
|
886
|
+
steps,
|
|
887
|
+
total,
|
|
888
|
+
page,
|
|
889
|
+
pageSize,
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
protected _buildJobsWhereClause(filters: GetJobsOptions['filters']) {
|
|
894
|
+
if (!filters) {
|
|
895
|
+
return undefined
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const jobsTable = this.tables.jobsTable
|
|
899
|
+
|
|
900
|
+
const fuzzySearch = filters.search?.trim()
|
|
901
|
+
|
|
902
|
+
// Build WHERE clause parts using postgres template literals
|
|
903
|
+
return and(
|
|
904
|
+
filters.status
|
|
905
|
+
? inArray(jobsTable.status, Array.isArray(filters.status) ? filters.status : [filters.status])
|
|
906
|
+
: undefined,
|
|
907
|
+
filters.actionName
|
|
908
|
+
? inArray(jobsTable.action_name, Array.isArray(filters.actionName) ? filters.actionName : [filters.actionName])
|
|
909
|
+
: undefined,
|
|
910
|
+
filters.groupKey && Array.isArray(filters.groupKey)
|
|
911
|
+
? sql`j.group_key LIKE ANY(ARRAY[${sql.raw(filters.groupKey.map((key) => `'${key}'`).join(','))}]::text[])`
|
|
912
|
+
: undefined,
|
|
913
|
+
filters.groupKey && !Array.isArray(filters.groupKey)
|
|
914
|
+
? ilike(jobsTable.group_key, `%${filters.groupKey}%`)
|
|
915
|
+
: undefined,
|
|
916
|
+
filters.ownerId
|
|
917
|
+
? inArray(jobsTable.owner_id, Array.isArray(filters.ownerId) ? filters.ownerId : [filters.ownerId])
|
|
918
|
+
: undefined,
|
|
919
|
+
filters.createdAt && Array.isArray(filters.createdAt)
|
|
920
|
+
? between(
|
|
921
|
+
sql`date_trunc('second', ${jobsTable.created_at})`,
|
|
922
|
+
filters.createdAt[0]!.toISOString(),
|
|
923
|
+
filters.createdAt[1]!.toISOString(),
|
|
924
|
+
)
|
|
925
|
+
: undefined,
|
|
926
|
+
filters.createdAt && !Array.isArray(filters.createdAt)
|
|
927
|
+
? gte(sql`date_trunc('second', ${jobsTable.created_at})`, filters.createdAt.toISOString())
|
|
928
|
+
: undefined,
|
|
929
|
+
filters.startedAt && Array.isArray(filters.startedAt)
|
|
930
|
+
? between(
|
|
931
|
+
sql`date_trunc('second', ${jobsTable.started_at})`,
|
|
932
|
+
filters.startedAt[0]!.toISOString(),
|
|
933
|
+
filters.startedAt[1]!.toISOString(),
|
|
934
|
+
)
|
|
935
|
+
: undefined,
|
|
936
|
+
filters.startedAt && !Array.isArray(filters.startedAt)
|
|
937
|
+
? gte(sql`date_trunc('second', ${jobsTable.started_at})`, filters.startedAt.toISOString())
|
|
938
|
+
: undefined,
|
|
939
|
+
filters.finishedAt && Array.isArray(filters.finishedAt)
|
|
940
|
+
? between(
|
|
941
|
+
sql`date_trunc('second', ${jobsTable.finished_at})`,
|
|
942
|
+
filters.finishedAt[0]!.toISOString(),
|
|
943
|
+
filters.finishedAt[1]!.toISOString(),
|
|
944
|
+
)
|
|
945
|
+
: undefined,
|
|
946
|
+
filters.finishedAt && !Array.isArray(filters.finishedAt)
|
|
947
|
+
? gte(sql`date_trunc('second', ${jobsTable.finished_at})`, filters.finishedAt.toISOString())
|
|
948
|
+
: undefined,
|
|
949
|
+
filters.updatedAfter
|
|
950
|
+
? sql`date_trunc('milliseconds', ${jobsTable.updated_at}) > ${filters.updatedAfter.toISOString()}::timestamptz`
|
|
951
|
+
: undefined,
|
|
952
|
+
fuzzySearch && fuzzySearch.length > 0
|
|
953
|
+
? or(
|
|
954
|
+
ilike(jobsTable.action_name, `%${fuzzySearch}%`),
|
|
955
|
+
ilike(jobsTable.group_key, `%${fuzzySearch}%`),
|
|
956
|
+
ilike(jobsTable.owner_id, `%${fuzzySearch}%`),
|
|
957
|
+
sql`${jobsTable.id}::text ilike ${`%${fuzzySearch}%`}`,
|
|
958
|
+
sql`to_tsvector('english', ${jobsTable.input}::text) @@ plainto_tsquery('english', ${fuzzySearch})`,
|
|
959
|
+
sql`to_tsvector('english', ${jobsTable.output}::text) @@ plainto_tsquery('english', ${fuzzySearch})`,
|
|
960
|
+
)
|
|
961
|
+
: undefined,
|
|
962
|
+
...(filters.inputFilter && Object.keys(filters.inputFilter).length > 0
|
|
963
|
+
? this.#buildJsonbWhereConditions(filters.inputFilter, jobsTable.input)
|
|
964
|
+
: []),
|
|
965
|
+
...(filters.outputFilter && Object.keys(filters.outputFilter).length > 0
|
|
966
|
+
? this.#buildJsonbWhereConditions(filters.outputFilter, jobsTable.output)
|
|
967
|
+
: []),
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Internal method to get jobs with pagination, filtering, and sorting.
|
|
972
|
+
* Does not include step information or job output.
|
|
973
|
+
*/
|
|
974
|
+
protected async _getJobs(options?: GetJobsOptions): Promise<GetJobsResult> {
|
|
975
|
+
const jobsTable = this.tables.jobsTable
|
|
976
|
+
const page = options?.page ?? 1
|
|
977
|
+
const pageSize = options?.pageSize ?? 10
|
|
978
|
+
const filters = options?.filters ?? {}
|
|
979
|
+
|
|
980
|
+
const sortInput = options?.sort ?? { field: 'startedAt', order: 'desc' }
|
|
981
|
+
const sorts = Array.isArray(sortInput) ? sortInput : [sortInput]
|
|
982
|
+
|
|
983
|
+
const where = this._buildJobsWhereClause(filters)
|
|
984
|
+
|
|
985
|
+
// Get total count
|
|
986
|
+
const total = await this.db.$count(jobsTable, where)
|
|
987
|
+
if (!total) {
|
|
988
|
+
return {
|
|
989
|
+
jobs: [],
|
|
990
|
+
total: 0,
|
|
991
|
+
page,
|
|
992
|
+
pageSize,
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const sortFieldMap: Record<JobSort['field'], any> = {
|
|
997
|
+
createdAt: jobsTable.created_at,
|
|
998
|
+
startedAt: jobsTable.started_at,
|
|
999
|
+
finishedAt: jobsTable.finished_at,
|
|
1000
|
+
status: jobsTable.status,
|
|
1001
|
+
actionName: jobsTable.action_name,
|
|
1002
|
+
expiresAt: jobsTable.expires_at,
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const jobs = await this.db
|
|
1006
|
+
.select({
|
|
1007
|
+
id: jobsTable.id,
|
|
1008
|
+
actionName: jobsTable.action_name,
|
|
1009
|
+
groupKey: jobsTable.group_key,
|
|
1010
|
+
input: jobsTable.input,
|
|
1011
|
+
output: jobsTable.output,
|
|
1012
|
+
error: jobsTable.error,
|
|
1013
|
+
status: jobsTable.status,
|
|
1014
|
+
timeoutMs: jobsTable.timeout_ms,
|
|
1015
|
+
expiresAt: jobsTable.expires_at,
|
|
1016
|
+
startedAt: jobsTable.started_at,
|
|
1017
|
+
finishedAt: jobsTable.finished_at,
|
|
1018
|
+
createdAt: jobsTable.created_at,
|
|
1019
|
+
updatedAt: jobsTable.updated_at,
|
|
1020
|
+
concurrencyLimit: jobsTable.concurrency_limit,
|
|
1021
|
+
})
|
|
1022
|
+
.from(jobsTable)
|
|
1023
|
+
.where(where)
|
|
1024
|
+
.orderBy(
|
|
1025
|
+
...sorts
|
|
1026
|
+
.filter((sortItem) => sortItem.field in sortFieldMap)
|
|
1027
|
+
.map((sortItem) => {
|
|
1028
|
+
const sortField = sortFieldMap[sortItem.field]
|
|
1029
|
+
if (sortItem.order.toUpperCase() === 'ASC') {
|
|
1030
|
+
return asc(sortField)
|
|
1031
|
+
} else {
|
|
1032
|
+
return desc(sortField)
|
|
1033
|
+
}
|
|
1034
|
+
}),
|
|
1035
|
+
)
|
|
1036
|
+
.limit(pageSize)
|
|
1037
|
+
.offset((page - 1) * pageSize)
|
|
1038
|
+
|
|
1039
|
+
return {
|
|
1040
|
+
jobs,
|
|
1041
|
+
total,
|
|
1042
|
+
page,
|
|
1043
|
+
pageSize,
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Internal method to get a step by its ID with all information.
|
|
1049
|
+
*/
|
|
1050
|
+
protected async _getJobStepById(stepId: string): Promise<JobStep | null> {
|
|
1051
|
+
const [step] = await this.db
|
|
1052
|
+
.select({
|
|
1053
|
+
id: this.tables.jobStepsTable.id,
|
|
1054
|
+
jobId: this.tables.jobStepsTable.job_id,
|
|
1055
|
+
name: this.tables.jobStepsTable.name,
|
|
1056
|
+
output: this.tables.jobStepsTable.output,
|
|
1057
|
+
status: this.tables.jobStepsTable.status,
|
|
1058
|
+
error: this.tables.jobStepsTable.error,
|
|
1059
|
+
startedAt: this.tables.jobStepsTable.started_at,
|
|
1060
|
+
finishedAt: this.tables.jobStepsTable.finished_at,
|
|
1061
|
+
timeoutMs: this.tables.jobStepsTable.timeout_ms,
|
|
1062
|
+
expiresAt: this.tables.jobStepsTable.expires_at,
|
|
1063
|
+
retriesLimit: this.tables.jobStepsTable.retries_limit,
|
|
1064
|
+
retriesCount: this.tables.jobStepsTable.retries_count,
|
|
1065
|
+
delayedMs: this.tables.jobStepsTable.delayed_ms,
|
|
1066
|
+
historyFailedAttempts: this.tables.jobStepsTable.history_failed_attempts,
|
|
1067
|
+
createdAt: this.tables.jobStepsTable.created_at,
|
|
1068
|
+
updatedAt: this.tables.jobStepsTable.updated_at,
|
|
1069
|
+
})
|
|
1070
|
+
.from(this.tables.jobStepsTable)
|
|
1071
|
+
.where(eq(this.tables.jobStepsTable.id, stepId))
|
|
1072
|
+
.limit(1)
|
|
1073
|
+
|
|
1074
|
+
return step ?? null
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Internal method to get job status and updatedAt timestamp.
|
|
1079
|
+
*/
|
|
1080
|
+
protected async _getJobStatus(jobId: string): Promise<JobStatusResult | null> {
|
|
1081
|
+
const [job] = await this.db
|
|
1082
|
+
.select({
|
|
1083
|
+
status: this.tables.jobsTable.status,
|
|
1084
|
+
updatedAt: this.tables.jobsTable.updated_at,
|
|
1085
|
+
})
|
|
1086
|
+
.from(this.tables.jobsTable)
|
|
1087
|
+
.where(eq(this.tables.jobsTable.id, jobId))
|
|
1088
|
+
.limit(1)
|
|
1089
|
+
|
|
1090
|
+
return job ?? null
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Internal method to get job step status and updatedAt timestamp.
|
|
1095
|
+
*/
|
|
1096
|
+
protected async _getJobStepStatus(stepId: string): Promise<JobStepStatusResult | null> {
|
|
1097
|
+
const [step] = await this.db
|
|
1098
|
+
.select({
|
|
1099
|
+
status: this.tables.jobStepsTable.status,
|
|
1100
|
+
updatedAt: this.tables.jobStepsTable.updated_at,
|
|
1101
|
+
})
|
|
1102
|
+
.from(this.tables.jobStepsTable)
|
|
1103
|
+
.where(eq(this.tables.jobStepsTable.id, stepId))
|
|
1104
|
+
.limit(1)
|
|
1105
|
+
|
|
1106
|
+
return step ?? null
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Internal method to get action statistics including counts and last job created date.
|
|
1111
|
+
*/
|
|
1112
|
+
protected async _getActions(): Promise<GetActionsResult> {
|
|
1113
|
+
const actionStats = this.db.$with('action_stats').as(
|
|
1114
|
+
this.db
|
|
1115
|
+
.select({
|
|
1116
|
+
name: this.tables.jobsTable.action_name,
|
|
1117
|
+
last_job_created: sql<Date | null>`MAX(${this.tables.jobsTable.created_at})`.as('last_job_created'),
|
|
1118
|
+
active: sql<number>`COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_ACTIVE})`.as(
|
|
1119
|
+
'active',
|
|
1120
|
+
),
|
|
1121
|
+
completed: sql<number>`COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_COMPLETED})`.as(
|
|
1122
|
+
'completed',
|
|
1123
|
+
),
|
|
1124
|
+
failed: sql<number>`COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_FAILED})`.as(
|
|
1125
|
+
'failed',
|
|
1126
|
+
),
|
|
1127
|
+
cancelled: sql<number>`COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_CANCELLED})`.as(
|
|
1128
|
+
'cancelled',
|
|
1129
|
+
),
|
|
1130
|
+
})
|
|
1131
|
+
.from(this.tables.jobsTable)
|
|
1132
|
+
.groupBy(this.tables.jobsTable.action_name),
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
const actions = await this.db
|
|
1136
|
+
.with(actionStats)
|
|
1137
|
+
.select({
|
|
1138
|
+
name: actionStats.name,
|
|
1139
|
+
lastJobCreated: actionStats.last_job_created,
|
|
1140
|
+
active: sql<number>`${actionStats.active}::int`,
|
|
1141
|
+
completed: sql<number>`${actionStats.completed}::int`,
|
|
1142
|
+
failed: sql<number>`${actionStats.failed}::int`,
|
|
1143
|
+
cancelled: sql<number>`${actionStats.cancelled}::int`,
|
|
1144
|
+
})
|
|
1145
|
+
.from(actionStats)
|
|
1146
|
+
.orderBy(actionStats.name)
|
|
1147
|
+
|
|
1148
|
+
return {
|
|
1149
|
+
actions: actions.map((action) => ({
|
|
1150
|
+
...action,
|
|
1151
|
+
lastJobCreated: action.lastJobCreated ?? null,
|
|
1152
|
+
})),
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// ============================================================================
|
|
1157
|
+
// Private Methods
|
|
1158
|
+
// ============================================================================
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Build WHERE conditions for JSONB filter using individual property checks.
|
|
1162
|
+
* Each property becomes a separate condition using ->> operator and ILIKE for case-insensitive matching.
|
|
1163
|
+
* Supports nested properties via dot notation and arrays.
|
|
1164
|
+
*
|
|
1165
|
+
* Example:
|
|
1166
|
+
* { "email": "tincho@gmail", "address.name": "nicolas", "products": ["chicle"] }
|
|
1167
|
+
* Generates:
|
|
1168
|
+
* input ->> 'email' ILIKE '%tincho@gmail%'
|
|
1169
|
+
* AND input ->> 'address' ->> 'name' ILIKE '%nicolas%'
|
|
1170
|
+
* AND EXISTS (SELECT 1 FROM jsonb_array_elements_text(input -> 'products') AS elem WHERE LOWER(elem) ILIKE LOWER('%chicle%'))
|
|
1171
|
+
*
|
|
1172
|
+
* @param filter - Flat record with dot-notation keys (e.g., { "email": "test", "address.name": "value", "products": ["chicle"] })
|
|
1173
|
+
* @param jsonbColumn - The JSONB column name
|
|
1174
|
+
* @returns Array of SQL conditions
|
|
1175
|
+
*/
|
|
1176
|
+
#buildJsonbWhereConditions(filter: Record<string, any>, jsonbColumn: PgColumn): any[] {
|
|
1177
|
+
const conditions: any[] = []
|
|
1178
|
+
|
|
1179
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
1180
|
+
const parts = key.split('.').filter((p) => p.length > 0)
|
|
1181
|
+
if (parts.length === 0) {
|
|
1182
|
+
continue
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Build the JSONB path expression step by step
|
|
1186
|
+
// For "address.name": input -> 'address' ->> 'name' (-> for intermediate, ->> for final)
|
|
1187
|
+
// For "email": input ->> 'email' (->> for single level)
|
|
1188
|
+
let jsonbPath = sql`${jsonbColumn}`
|
|
1189
|
+
if (parts.length === 1) {
|
|
1190
|
+
// Single level: use ->> directly
|
|
1191
|
+
jsonbPath = sql`${jsonbPath} ->> ${parts[0]!}`
|
|
1192
|
+
} else {
|
|
1193
|
+
// Nested: use -> for intermediate steps, ->> for final step
|
|
1194
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
1195
|
+
const part = parts[i]
|
|
1196
|
+
if (part) {
|
|
1197
|
+
jsonbPath = sql`${jsonbPath} -> ${part}`
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const lastPart = parts[parts.length - 1]
|
|
1201
|
+
if (lastPart) {
|
|
1202
|
+
jsonbPath = sql`${jsonbPath} ->> ${lastPart}`
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Handle array values - check if JSONB array contains at least one of the values
|
|
1207
|
+
if (Array.isArray(value)) {
|
|
1208
|
+
// Build condition: check if any element in the JSONB array matches any value in the filter array
|
|
1209
|
+
const arrayValueConditions = value.map((arrayValue) => {
|
|
1210
|
+
const arrayValueStr = String(arrayValue)
|
|
1211
|
+
// Get the array from JSONB: input -> 'products'
|
|
1212
|
+
let arrayPath = sql`${jsonbColumn}`
|
|
1213
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
1214
|
+
const part = parts[i]
|
|
1215
|
+
if (part) {
|
|
1216
|
+
arrayPath = sql`${arrayPath} -> ${part}`
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
const lastPart = parts[parts.length - 1]
|
|
1220
|
+
if (lastPart) {
|
|
1221
|
+
arrayPath = sql`${arrayPath} -> ${lastPart}`
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Check if the JSONB array contains the value (case-insensitive for strings)
|
|
1225
|
+
if (typeof arrayValue === 'string') {
|
|
1226
|
+
return sql`EXISTS (
|
|
1227
|
+
SELECT 1
|
|
1228
|
+
FROM jsonb_array_elements_text(${arrayPath}) AS elem
|
|
1229
|
+
WHERE LOWER(elem) ILIKE LOWER(${`%${arrayValueStr}%`})
|
|
1230
|
+
)`
|
|
1231
|
+
} else {
|
|
1232
|
+
// For non-string values, use exact containment
|
|
1233
|
+
return sql`${arrayPath} @> ${sql.raw(JSON.stringify([arrayValue]))}::jsonb`
|
|
1234
|
+
}
|
|
1235
|
+
})
|
|
1236
|
+
|
|
1237
|
+
// Combine array conditions with OR (at least one must match)
|
|
1238
|
+
if (arrayValueConditions.length > 0) {
|
|
1239
|
+
conditions.push(
|
|
1240
|
+
arrayValueConditions.reduce((acc, condition, idx) => (idx === 0 ? condition : sql`${acc} OR ${condition}`)),
|
|
1241
|
+
)
|
|
1242
|
+
}
|
|
1243
|
+
} else if (typeof value === 'string') {
|
|
1244
|
+
// String values: use ILIKE for case-insensitive partial matching
|
|
1245
|
+
conditions.push(sql`COALESCE(${jsonbPath}, '') ILIKE ${`%${value}%`}`)
|
|
1246
|
+
} else {
|
|
1247
|
+
// Non-string, non-array values: use exact match
|
|
1248
|
+
// Convert JSONB value to text for comparison
|
|
1249
|
+
conditions.push(sql`${jsonbPath}::text = ${String(value)}`)
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
return conditions
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// ============================================================================
|
|
1257
|
+
// Protected Methods
|
|
1258
|
+
// ============================================================================
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Send a PostgreSQL notification.
|
|
1262
|
+
*
|
|
1263
|
+
* @param event - The event name
|
|
1264
|
+
* @param data - The data to send
|
|
1265
|
+
* @returns Promise resolving to `void`
|
|
1266
|
+
*/
|
|
1267
|
+
protected async _notify(_event: string, _data: any): Promise<void> {
|
|
1268
|
+
// do nothing
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* Listen for PostgreSQL notifications.
|
|
1273
|
+
*
|
|
1274
|
+
* @param event - The event name to listen for
|
|
1275
|
+
* @param callback - Callback function to handle notifications
|
|
1276
|
+
* @returns Promise resolving to an object with an `unlisten` function
|
|
1277
|
+
*/
|
|
1278
|
+
protected async _listen(_event: string, _callback: (payload: string) => void): Promise<{ unlisten: () => void }> {
|
|
1279
|
+
// do nothing
|
|
1280
|
+
return {
|
|
1281
|
+
unlisten: () => {
|
|
1282
|
+
// do nothing
|
|
1283
|
+
},
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Map database query results to the expected format.
|
|
1289
|
+
* Can be overridden by subclasses to handle different result formats.
|
|
1290
|
+
*
|
|
1291
|
+
* @param result - The raw database query result
|
|
1292
|
+
* @returns The mapped result
|
|
1293
|
+
*/
|
|
1294
|
+
protected _map(result: any) {
|
|
1295
|
+
return result
|
|
1296
|
+
}
|
|
1297
|
+
}
|