bunqueue 2.8.9 → 2.8.11
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/application/operations/ack.d.ts +1 -1
- package/dist/application/operations/ack.js +8 -2
- package/dist/application/queueManager.d.ts +1 -1
- package/dist/application/queueManager.js +2 -2
- package/dist/cli/client.d.ts +9 -0
- package/dist/cli/client.js +35 -9
- package/dist/cli/commands/backup.js +3 -3
- package/dist/cli/commands/cron.js +16 -3
- package/dist/cli/commands/job.js +5 -1
- package/dist/cli/commands/server.d.ts +3 -1
- package/dist/cli/commands/server.js +7 -105
- package/dist/cli/commands/webhook.js +3 -9
- package/dist/cli/help.js +3 -1
- package/dist/cli/index.js +112 -44
- package/dist/cli/output.js +57 -3
- package/dist/client/jobConversionHelpers.js +6 -3
- package/dist/client/queue/jobProxy.d.ts +4 -0
- package/dist/client/queue/jobProxy.js +14 -6
- package/dist/client/queue/operations/query.js +20 -2
- package/dist/client/worker/processor.js +14 -7
- package/dist/domain/types/command.d.ts +2 -0
- package/dist/domain/types/cron.js +3 -1
- package/dist/domain/types/job.d.ts +8 -0
- package/dist/domain/types/job.js +21 -0
- package/dist/domain/types/webhook.d.ts +12 -2
- package/dist/domain/types/webhook.js +13 -0
- package/dist/infrastructure/persistence/schema.d.ts +2 -2
- package/dist/infrastructure/persistence/schema.js +7 -2
- package/dist/infrastructure/persistence/sqlite.js +2 -2
- package/dist/infrastructure/persistence/sqliteSerializer.js +5 -0
- package/dist/infrastructure/persistence/statements.d.ts +1 -0
- package/dist/infrastructure/server/bootstrap.d.ts +9 -0
- package/dist/infrastructure/server/bootstrap.js +217 -0
- package/dist/infrastructure/server/handlers/advanced.js +8 -0
- package/dist/infrastructure/server/handlers/core.js +7 -1
- package/dist/infrastructure/server/handlers/monitoring.js +7 -0
- package/dist/infrastructure/server/httpRouteJobs.js +2 -0
- package/dist/main.js +12 -224
- package/dist/mcp/tools/webhookTools.js +2 -10
- package/package.json +1 -1
package/dist/cli/output.js
CHANGED
|
@@ -143,6 +143,15 @@ function formatStats(stats) {
|
|
|
143
143
|
lines.push(` Total Completed: ${str(stats.totalCompleted)}`);
|
|
144
144
|
lines.push(` Total Failed: ${str(stats.totalFailed)}`);
|
|
145
145
|
}
|
|
146
|
+
if (stats.uptime !== undefined) {
|
|
147
|
+
lines.push('', ` ${color('Uptime:', colors.cyan)} ${str(stats.uptime)}s`);
|
|
148
|
+
}
|
|
149
|
+
if (stats.pushPerSec !== undefined) {
|
|
150
|
+
lines.push(` Push/sec: ${str(stats.pushPerSec)}`);
|
|
151
|
+
}
|
|
152
|
+
if (stats.pullPerSec !== undefined) {
|
|
153
|
+
lines.push(` Pull/sec: ${str(stats.pullPerSec)}`);
|
|
154
|
+
}
|
|
146
155
|
return lines.join('\n');
|
|
147
156
|
}
|
|
148
157
|
/** Format counts object */
|
|
@@ -167,7 +176,17 @@ function formatCronJobs(jobs) {
|
|
|
167
176
|
const schedule = job.schedule !== null && job.schedule !== undefined
|
|
168
177
|
? str(job.schedule)
|
|
169
178
|
: `every ${str(job.repeatEvery)}ms`;
|
|
170
|
-
|
|
179
|
+
let out = ` ${color(str(job.name), colors.bold)}\n Queue: ${str(job.queue)}\n Schedule: ${schedule}\n Executions: ${str(job.executions)}`;
|
|
180
|
+
if (typeof job.nextRun === 'number') {
|
|
181
|
+
out += `\n Next run: ${new Date(job.nextRun).toISOString()}`;
|
|
182
|
+
}
|
|
183
|
+
if (job.maxLimit !== null && job.maxLimit !== undefined) {
|
|
184
|
+
out += `\n Max: ${str(job.maxLimit)}`;
|
|
185
|
+
}
|
|
186
|
+
if (job.timezone !== null && job.timezone !== undefined) {
|
|
187
|
+
out += `\n Timezone: ${str(job.timezone)}`;
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
171
190
|
});
|
|
172
191
|
return lines.join('\n\n');
|
|
173
192
|
}
|
|
@@ -177,7 +196,25 @@ function formatWorkers(workers) {
|
|
|
177
196
|
return color('No workers registered', colors.yellow);
|
|
178
197
|
}
|
|
179
198
|
return workers
|
|
180
|
-
.map((w) =>
|
|
199
|
+
.map((w) => {
|
|
200
|
+
const queues = Array.isArray(w.queues) ? w.queues.join(', ') : 'none';
|
|
201
|
+
// status matters operationally: a stale worker must be visible
|
|
202
|
+
const status = w.status === 'stale'
|
|
203
|
+
? color('[stale]', colors.red)
|
|
204
|
+
: w.status !== undefined
|
|
205
|
+
? color(`[${str(w.status)}]`, colors.green)
|
|
206
|
+
: '';
|
|
207
|
+
const extra = [];
|
|
208
|
+
if (w.concurrency !== undefined)
|
|
209
|
+
extra.push(`concurrency=${str(w.concurrency)}`);
|
|
210
|
+
if (w.activeJobs !== undefined)
|
|
211
|
+
extra.push(`active=${str(w.activeJobs)}`);
|
|
212
|
+
if (w.processedJobs !== undefined) {
|
|
213
|
+
extra.push(`processed=${str(w.processedJobs)}/failed=${str(w.failedJobs, '0')}`);
|
|
214
|
+
}
|
|
215
|
+
const extraStr = extra.length > 0 ? `\n ${extra.join(' ')}` : '';
|
|
216
|
+
return ` ${color(str(w.id), colors.bold)}: ${str(w.name)} ${status} (${queues})${extraStr}`;
|
|
217
|
+
})
|
|
181
218
|
.join('\n');
|
|
182
219
|
}
|
|
183
220
|
/** Format webhooks list */
|
|
@@ -186,7 +223,15 @@ function formatWebhooks(webhooks) {
|
|
|
186
223
|
return color('No webhooks registered', colors.yellow);
|
|
187
224
|
}
|
|
188
225
|
return webhooks
|
|
189
|
-
.map((w) =>
|
|
226
|
+
.map((w) => {
|
|
227
|
+
const events = Array.isArray(w.events) ? w.events.join(', ') : 'none';
|
|
228
|
+
const enabled = w.enabled === false ? ` ${color('[disabled]', colors.yellow)}` : '';
|
|
229
|
+
const queue = w.queue !== null && w.queue !== undefined ? `\n Queue: ${str(w.queue)}` : '';
|
|
230
|
+
const counters = w.successCount !== undefined || w.failureCount !== undefined
|
|
231
|
+
? `\n Delivered: ${str(w.successCount, '0')} ok / ${str(w.failureCount, '0')} failed`
|
|
232
|
+
: '';
|
|
233
|
+
return ` ${color(str(w.id), colors.bold)}: ${str(w.url)}${enabled}\n Events: ${events}${queue}${counters}`;
|
|
234
|
+
})
|
|
190
235
|
.join('\n\n');
|
|
191
236
|
}
|
|
192
237
|
/** Format DLQ jobs */
|
|
@@ -300,6 +345,15 @@ function formatSuccess(response, command, subcommand) {
|
|
|
300
345
|
// Worker registered
|
|
301
346
|
if ('workerId' in r)
|
|
302
347
|
return color(`Worker registered: ${str(r.workerId)}`, colors.green);
|
|
348
|
+
// Webhook added — show the id, it is needed for `webhook remove`
|
|
349
|
+
if ('webhookId' in r)
|
|
350
|
+
return color(`Webhook added: ${str(r.webhookId)}`, colors.green);
|
|
351
|
+
// Cron scheduled — surface nextRun
|
|
352
|
+
if ('cron' in r && r.cron !== null && typeof r.cron === 'object') {
|
|
353
|
+
const c = r.cron;
|
|
354
|
+
const next = typeof c.nextRun === 'number' ? ` (next run: ${new Date(c.nextRun).toISOString()})` : '';
|
|
355
|
+
return color(`Cron scheduled: ${str(c.name)}${next}`, colors.green);
|
|
356
|
+
}
|
|
303
357
|
// State
|
|
304
358
|
if ('state' in r)
|
|
305
359
|
return `State: ${str(r.state)}`;
|
|
@@ -20,7 +20,8 @@ export function buildJobProperties(job, name, stacktrace, token, processedBy) {
|
|
|
20
20
|
delay: job.runAt > job.createdAt ? job.runAt - job.createdAt : 0,
|
|
21
21
|
processedOn: job.startedAt ?? undefined,
|
|
22
22
|
finishedOn: job.completedAt ?? undefined,
|
|
23
|
-
|
|
23
|
+
// Fall back to the stack persisted on the internal job at FAIL time (#74)
|
|
24
|
+
stacktrace: stacktrace ?? job.stacktrace ?? null,
|
|
24
25
|
stalledCounter: job.stallCount,
|
|
25
26
|
priority: job.priority,
|
|
26
27
|
parentKey: buildParentKey(job),
|
|
@@ -66,6 +67,8 @@ export function buildStateCheckMethods(id, getState, getDependenciesCount) {
|
|
|
66
67
|
}
|
|
67
68
|
/** Build serialization methods */
|
|
68
69
|
export function buildSerializationMethods(job, id, name, jobOpts, stacktrace) {
|
|
70
|
+
// Fall back to the stack persisted on the internal job at FAIL time (#74)
|
|
71
|
+
const stack = stacktrace ?? job.stacktrace ?? null;
|
|
69
72
|
return {
|
|
70
73
|
toJSON: () => ({
|
|
71
74
|
id,
|
|
@@ -76,7 +79,7 @@ export function buildSerializationMethods(job, id, name, jobOpts, stacktrace) {
|
|
|
76
79
|
delay: job.runAt > job.createdAt ? job.runAt - job.createdAt : 0,
|
|
77
80
|
timestamp: job.createdAt,
|
|
78
81
|
attemptsMade: job.attempts,
|
|
79
|
-
stacktrace:
|
|
82
|
+
stacktrace: stack,
|
|
80
83
|
returnvalue: undefined,
|
|
81
84
|
failedReason: undefined,
|
|
82
85
|
finishedOn: job.completedAt ?? undefined,
|
|
@@ -93,7 +96,7 @@ export function buildSerializationMethods(job, id, name, jobOpts, stacktrace) {
|
|
|
93
96
|
delay: String(job.runAt > job.createdAt ? job.runAt - job.createdAt : 0),
|
|
94
97
|
timestamp: String(job.createdAt),
|
|
95
98
|
attemptsMade: String(job.attempts),
|
|
96
|
-
stacktrace:
|
|
99
|
+
stacktrace: stack ? JSON.stringify(stack) : null,
|
|
97
100
|
returnvalue: undefined,
|
|
98
101
|
failedReason: undefined,
|
|
99
102
|
finishedOn: job.completedAt ? String(job.completedAt) : undefined,
|
|
@@ -22,6 +22,10 @@ export interface JobReflectionMeta {
|
|
|
22
22
|
delay?: number;
|
|
23
23
|
opts?: JobOptions;
|
|
24
24
|
timestamp?: number;
|
|
25
|
+
/** Persisted stacktrace of the last failure, from the server/manager job (#74) */
|
|
26
|
+
stacktrace?: string[] | null;
|
|
27
|
+
/** Last failure's error message, derived from the job timeline (#74) */
|
|
28
|
+
failedReason?: string;
|
|
25
29
|
}
|
|
26
30
|
/** Create a full Job proxy with TCP methods */
|
|
27
31
|
export declare function createJobProxy<T>(id: string, name: string, data: T, ctx: JobProxyContext, meta?: JobReflectionMeta): Job<T>;
|
|
@@ -17,6 +17,8 @@ function reflectFields(id, queueName, meta) {
|
|
|
17
17
|
parentKey: p ? `${p.queue}:${p.id}` : undefined,
|
|
18
18
|
parent: p ? { id: p.id, queueQualifiedName: p.queue } : undefined,
|
|
19
19
|
repeatJobKey: repeat ? `${queueName}:${id}:${pattern}` : undefined,
|
|
20
|
+
stacktrace: meta?.stacktrace ?? null,
|
|
21
|
+
failedReason: meta?.failedReason,
|
|
20
22
|
};
|
|
21
23
|
}
|
|
22
24
|
/** Create a full Job proxy with TCP methods */
|
|
@@ -35,7 +37,8 @@ export function createJobProxy(id, name, data, ctx, meta) {
|
|
|
35
37
|
delay: r.delay,
|
|
36
38
|
processedOn: undefined,
|
|
37
39
|
finishedOn: undefined,
|
|
38
|
-
stacktrace:
|
|
40
|
+
stacktrace: r.stacktrace,
|
|
41
|
+
failedReason: r.failedReason,
|
|
39
42
|
stalledCounter: 0,
|
|
40
43
|
priority: r.priority,
|
|
41
44
|
parent: r.parent,
|
|
@@ -103,7 +106,8 @@ export function createJobProxy(id, name, data, ctx, meta) {
|
|
|
103
106
|
delay: r.delay,
|
|
104
107
|
timestamp: ts,
|
|
105
108
|
attemptsMade: 0,
|
|
106
|
-
stacktrace:
|
|
109
|
+
stacktrace: r.stacktrace,
|
|
110
|
+
failedReason: r.failedReason,
|
|
107
111
|
queueQualifiedName: `bull:${queueName}`,
|
|
108
112
|
parentKey: r.parentKey,
|
|
109
113
|
}),
|
|
@@ -116,7 +120,8 @@ export function createJobProxy(id, name, data, ctx, meta) {
|
|
|
116
120
|
delay: String(r.delay),
|
|
117
121
|
timestamp: String(ts),
|
|
118
122
|
attemptsMade: '0',
|
|
119
|
-
stacktrace: null,
|
|
123
|
+
stacktrace: r.stacktrace ? JSON.stringify(r.stacktrace) : null,
|
|
124
|
+
failedReason: r.failedReason,
|
|
120
125
|
parentKey: r.parentKey,
|
|
121
126
|
}),
|
|
122
127
|
// Move methods
|
|
@@ -212,7 +217,8 @@ export function createSimpleJob(id, name, data, timestamp, ctx) {
|
|
|
212
217
|
delay: r.delay,
|
|
213
218
|
processedOn: undefined,
|
|
214
219
|
finishedOn: undefined,
|
|
215
|
-
stacktrace:
|
|
220
|
+
stacktrace: r.stacktrace,
|
|
221
|
+
failedReason: r.failedReason,
|
|
216
222
|
stalledCounter: 0,
|
|
217
223
|
priority: r.priority,
|
|
218
224
|
parent: r.parent,
|
|
@@ -318,7 +324,8 @@ export function createSimpleJob(id, name, data, timestamp, ctx) {
|
|
|
318
324
|
delay: r.delay,
|
|
319
325
|
timestamp,
|
|
320
326
|
attemptsMade: 0,
|
|
321
|
-
stacktrace:
|
|
327
|
+
stacktrace: r.stacktrace,
|
|
328
|
+
failedReason: r.failedReason,
|
|
322
329
|
queueQualifiedName: `bull:${queueName}`,
|
|
323
330
|
parentKey: r.parentKey,
|
|
324
331
|
}),
|
|
@@ -331,7 +338,8 @@ export function createSimpleJob(id, name, data, timestamp, ctx) {
|
|
|
331
338
|
delay: String(r.delay),
|
|
332
339
|
timestamp: String(timestamp),
|
|
333
340
|
attemptsMade: '0',
|
|
334
|
-
stacktrace: null,
|
|
341
|
+
stacktrace: r.stacktrace ? JSON.stringify(r.stacktrace) : null,
|
|
342
|
+
failedReason: r.failedReason,
|
|
335
343
|
parentKey: r.parentKey,
|
|
336
344
|
}),
|
|
337
345
|
moveToCompleted: async (returnValue) => {
|
|
@@ -8,10 +8,28 @@ import { toPublicJob } from '../../types';
|
|
|
8
8
|
import { jobId } from '../../../domain/types/job';
|
|
9
9
|
import { createSimpleJob } from '../jobProxy';
|
|
10
10
|
import { buildJobOpts } from '../../jobHelpers';
|
|
11
|
-
/**
|
|
11
|
+
/** Last failure's error message from the job timeline (#74) */
|
|
12
|
+
function lastFailedError(timeline) {
|
|
13
|
+
if (!timeline)
|
|
14
|
+
return undefined;
|
|
15
|
+
for (let i = timeline.length - 1; i >= 0; i--) {
|
|
16
|
+
const entry = timeline[i];
|
|
17
|
+
if (entry.state === 'failed' && entry.error)
|
|
18
|
+
return entry.error;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
/** Build reflection meta (priority/delay/opts/stacktrace) from an internal job shape */
|
|
12
23
|
function metaFromJob(job) {
|
|
13
24
|
const opts = buildJobOpts(job);
|
|
14
|
-
return {
|
|
25
|
+
return {
|
|
26
|
+
priority: job.priority ?? 0,
|
|
27
|
+
delay: opts.delay ?? 0,
|
|
28
|
+
opts,
|
|
29
|
+
// Persisted server-side on FAIL (#74); absent on pre-#74 servers
|
|
30
|
+
stacktrace: job.stacktrace ?? null,
|
|
31
|
+
failedReason: lastFailedError(job.timeline),
|
|
32
|
+
};
|
|
15
33
|
}
|
|
16
34
|
/** Get a single job by ID */
|
|
17
35
|
export async function getJob(ctx, id) {
|
|
@@ -157,16 +157,27 @@ async function handleJobFailure(internalJob, error, config, context) {
|
|
|
157
157
|
internalJob.maxAttempts = 1;
|
|
158
158
|
internalJob.attempts = 0;
|
|
159
159
|
}
|
|
160
|
+
// Bug #74: stack lines computed BEFORE the send so the server can persist
|
|
161
|
+
// them. The wire copy is capped at 50 lines as a bandwidth guard; the
|
|
162
|
+
// authoritative cap (job.stackTraceLimit) is applied server-side in failJob.
|
|
163
|
+
const stackLines = err.stack
|
|
164
|
+
? err.stack
|
|
165
|
+
.split('\n')
|
|
166
|
+
.map((l) => l.trim())
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
: [];
|
|
169
|
+
const wireStack = stackLines.length > 0 ? stackLines.slice(0, 50) : undefined;
|
|
160
170
|
try {
|
|
161
171
|
if (embedded) {
|
|
162
172
|
const manager = getSharedManager();
|
|
163
|
-
await manager.fail(internalJob.id, err.message, token ?? undefined);
|
|
173
|
+
await manager.fail(internalJob.id, err.message, token ?? undefined, undefined, wireStack);
|
|
164
174
|
}
|
|
165
175
|
else if (tcp) {
|
|
166
176
|
await tcp.send({
|
|
167
177
|
cmd: 'FAIL',
|
|
168
178
|
id: internalJob.id,
|
|
169
179
|
error: err.message,
|
|
180
|
+
...(wireStack ? { stack: wireStack } : {}),
|
|
170
181
|
...(token ? { token } : {}),
|
|
171
182
|
...(err instanceof UnrecoverableError ? { unrecoverable: true } : {}),
|
|
172
183
|
});
|
|
@@ -180,14 +191,10 @@ async function handleJobFailure(internalJob, error, config, context) {
|
|
|
180
191
|
emitter.emit('error', Object.assign(wrappedError, { context: 'fail', jobId: jobIdStr }));
|
|
181
192
|
}
|
|
182
193
|
job.failedReason = err.message;
|
|
183
|
-
// Bug #74: populate stacktrace
|
|
194
|
+
// Bug #74: populate stacktrace on the local `failed` event object
|
|
184
195
|
if (err.stack) {
|
|
185
196
|
const limit = internalJob.stackTraceLimit;
|
|
186
|
-
|
|
187
|
-
.split('\n')
|
|
188
|
-
.map((l) => l.trim())
|
|
189
|
-
.filter(Boolean);
|
|
190
|
-
job.stacktrace = lines.slice(0, limit);
|
|
197
|
+
job.stacktrace = stackLines.slice(0, limit);
|
|
191
198
|
}
|
|
192
199
|
config.onOutcome?.(false);
|
|
193
200
|
emitter.emit('failed', job, err);
|
|
@@ -100,6 +100,8 @@ export interface FailCommand extends BaseCommand {
|
|
|
100
100
|
readonly token?: string;
|
|
101
101
|
/** Skip all remaining retries and fail terminally (UnrecoverableError over TCP). */
|
|
102
102
|
readonly unrecoverable?: boolean;
|
|
103
|
+
/** Stack trace lines of the failure — persisted server-side, capped at job.stackTraceLimit (#74). */
|
|
104
|
+
readonly stack?: string[];
|
|
103
105
|
}
|
|
104
106
|
export interface GetJobCommand extends BaseCommand {
|
|
105
107
|
readonly cmd: 'GetJob';
|
|
@@ -16,7 +16,9 @@ export function createCronJob(input, nextRun) {
|
|
|
16
16
|
timezone: input.timezone ?? null,
|
|
17
17
|
nextRun,
|
|
18
18
|
executions: 0,
|
|
19
|
-
|
|
19
|
+
// 0/negative mean "no limit" on every surface (CLI/HTTP/TCP/MCP): storing
|
|
20
|
+
// 0 would make isAtLimit treat the cron as already exhausted (0 >= 0).
|
|
21
|
+
maxLimit: input.maxLimit !== undefined && input.maxLimit > 0 ? input.maxLimit : null,
|
|
20
22
|
uniqueKey: input.uniqueKey ?? null,
|
|
21
23
|
dedup: input.dedup ?? null,
|
|
22
24
|
skipMissedOnRestart: input.skipMissedOnRestart ?? true,
|
|
@@ -94,6 +94,8 @@ export interface Job {
|
|
|
94
94
|
readonly groupId: string | null;
|
|
95
95
|
progress: number;
|
|
96
96
|
progressMessage: string | null;
|
|
97
|
+
/** Last failure's stack trace lines (trimmed, capped at stackTraceLimit). Null until a FAIL carries a stack. (#74) */
|
|
98
|
+
stacktrace: string[] | null;
|
|
97
99
|
readonly removeOnComplete: boolean;
|
|
98
100
|
readonly removeOnFail: boolean;
|
|
99
101
|
readonly repeat: RepeatConfig | null;
|
|
@@ -220,6 +222,12 @@ export declare const JOB_DEFAULTS: {
|
|
|
220
222
|
};
|
|
221
223
|
/** Create a new job from input */
|
|
222
224
|
export declare function createJob(id: JobId, queue: string, input: JobInput, now?: number): Job;
|
|
225
|
+
/**
|
|
226
|
+
* Normalize raw stack lines for persistence: keep only strings, trim, drop
|
|
227
|
+
* empties, cap at `limit` (job.stackTraceLimit). Returns null when nothing
|
|
228
|
+
* remains (e.g. stackTraceLimit: 0), matching the 2.6.110 event semantics. (#74)
|
|
229
|
+
*/
|
|
230
|
+
export declare function normalizeStacktrace(lines: readonly unknown[] | undefined, limit: number): string[] | null;
|
|
223
231
|
/** Check if job is delayed */
|
|
224
232
|
export declare function isDelayed(job: Job, now?: number): boolean;
|
|
225
233
|
/** Check if job is ready to process */
|
package/dist/domain/types/job.js
CHANGED
|
@@ -122,6 +122,7 @@ export function createJob(id, queue, input, now = Date.now()) {
|
|
|
122
122
|
tags: input.tags ?? [],
|
|
123
123
|
progress: 0,
|
|
124
124
|
progressMessage: null,
|
|
125
|
+
stacktrace: null,
|
|
125
126
|
repeat: parseRepeatConfig(input.repeat),
|
|
126
127
|
lastHeartbeat: createdAt,
|
|
127
128
|
stallCount: 0,
|
|
@@ -131,6 +132,26 @@ export function createJob(id, queue, input, now = Date.now()) {
|
|
|
131
132
|
timeline: [],
|
|
132
133
|
};
|
|
133
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Normalize raw stack lines for persistence: keep only strings, trim, drop
|
|
137
|
+
* empties, cap at `limit` (job.stackTraceLimit). Returns null when nothing
|
|
138
|
+
* remains (e.g. stackTraceLimit: 0), matching the 2.6.110 event semantics. (#74)
|
|
139
|
+
*/
|
|
140
|
+
export function normalizeStacktrace(lines, limit) {
|
|
141
|
+
if (!lines || lines.length === 0)
|
|
142
|
+
return null;
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
if (out.length >= limit)
|
|
146
|
+
break;
|
|
147
|
+
if (typeof line !== 'string')
|
|
148
|
+
continue;
|
|
149
|
+
const trimmed = line.trim();
|
|
150
|
+
if (trimmed)
|
|
151
|
+
out.push(trimmed);
|
|
152
|
+
}
|
|
153
|
+
return out.length > 0 ? out : null;
|
|
154
|
+
}
|
|
134
155
|
/** Check if job is delayed */
|
|
135
156
|
export function isDelayed(job, now = Date.now()) {
|
|
136
157
|
return job.runAt > now;
|
|
@@ -3,8 +3,18 @@
|
|
|
3
3
|
*/
|
|
4
4
|
/** Webhook ID type */
|
|
5
5
|
export type WebhookId = string;
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Canonical list of webhook events the server actually triggers — single
|
|
8
|
+
* source of truth for CLI/TCP/HTTP/MCP validation. job.pushed/started/
|
|
9
|
+
* completed/failed flow through eventsManager.mapEventToWebhook; job.progress
|
|
10
|
+
* is triggered directly by updateProgress (jobManagement).
|
|
11
|
+
*/
|
|
12
|
+
export declare const WEBHOOK_EVENTS: readonly ["job.pushed", "job.started", "job.completed", "job.failed", "job.progress"];
|
|
13
|
+
/**
|
|
14
|
+
* Webhook event types. Includes 'job.stalled' for backward compatibility with
|
|
15
|
+
* stored webhooks, but it is never emitted and not accepted on new ones.
|
|
16
|
+
*/
|
|
17
|
+
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number] | 'job.stalled';
|
|
8
18
|
/** Webhook configuration */
|
|
9
19
|
export interface Webhook {
|
|
10
20
|
id: WebhookId;
|
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
* Webhook domain types
|
|
3
3
|
*/
|
|
4
4
|
import { uuid } from '../../shared/hash';
|
|
5
|
+
/**
|
|
6
|
+
* Canonical list of webhook events the server actually triggers — single
|
|
7
|
+
* source of truth for CLI/TCP/HTTP/MCP validation. job.pushed/started/
|
|
8
|
+
* completed/failed flow through eventsManager.mapEventToWebhook; job.progress
|
|
9
|
+
* is triggered directly by updateProgress (jobManagement).
|
|
10
|
+
*/
|
|
11
|
+
export const WEBHOOK_EVENTS = [
|
|
12
|
+
'job.pushed',
|
|
13
|
+
'job.started',
|
|
14
|
+
'job.completed',
|
|
15
|
+
'job.failed',
|
|
16
|
+
'job.progress',
|
|
17
|
+
];
|
|
5
18
|
/** Create a new webhook */
|
|
6
19
|
export function createWebhook(url, events, queue, secret) {
|
|
7
20
|
return {
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
/** SQLite PRAGMA settings for optimal performance */
|
|
5
5
|
export declare const PRAGMA_SETTINGS = "\nPRAGMA journal_mode = WAL;\nPRAGMA synchronous = NORMAL;\nPRAGMA cache_size = -64000;\nPRAGMA temp_store = MEMORY;\nPRAGMA mmap_size = 268435456;\nPRAGMA page_size = 4096;\nPRAGMA busy_timeout = 5000;\n";
|
|
6
6
|
/** Main schema creation */
|
|
7
|
-
export declare const SCHEMA = "\n-- Jobs table (using UUIDv7 for job IDs)\n-- Uses BLOB for data fields (MessagePack serialization for ~2-3x faster than JSON)\nCREATE TABLE IF NOT EXISTS jobs (\n id TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n priority INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL,\n run_at INTEGER NOT NULL,\n started_at INTEGER,\n completed_at INTEGER,\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n backoff INTEGER NOT NULL DEFAULT 1000,\n ttl INTEGER,\n timeout INTEGER,\n unique_key TEXT,\n custom_id TEXT,\n depends_on BLOB,\n parent_id TEXT,\n children_ids BLOB,\n tags BLOB,\n state TEXT NOT NULL DEFAULT 'waiting',\n lifo INTEGER NOT NULL DEFAULT 0,\n group_id TEXT,\n progress INTEGER DEFAULT 0,\n progress_msg TEXT,\n remove_on_complete INTEGER DEFAULT 0,\n remove_on_fail INTEGER DEFAULT 0,\n stall_timeout INTEGER,\n last_heartbeat INTEGER,\n timeline BLOB\n);\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state\n ON jobs(queue, state);\nCREATE INDEX IF NOT EXISTS idx_jobs_run_at\n ON jobs(run_at) WHERE state IN ('waiting', 'delayed');\nCREATE INDEX IF NOT EXISTS idx_jobs_unique\n ON jobs(queue, unique_key) WHERE unique_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_custom_id\n ON jobs(custom_id) WHERE custom_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_parent\n ON jobs(parent_id) WHERE parent_id IS NOT NULL;\n\n-- Job results storage (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS job_results (\n job_id TEXT PRIMARY KEY,\n result BLOB,\n completed_at INTEGER NOT NULL\n);\n\n-- Dead letter queue (BLOB for MessagePack - stores full DlqEntry)\nCREATE TABLE IF NOT EXISTS dlq (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n job_id TEXT NOT NULL,\n queue TEXT NOT NULL,\n entry BLOB NOT NULL,\n entered_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_dlq_queue ON dlq(queue);\nCREATE INDEX IF NOT EXISTS idx_dlq_job_id ON dlq(job_id);\nCREATE INDEX IF NOT EXISTS idx_dlq_entered_at ON dlq(entered_at);\n\n-- Performance indexes for high-throughput operations\n-- Stall detection: runs every 5s, needs fast lookup of active jobs by started_at\nCREATE INDEX IF NOT EXISTS idx_jobs_state_started\n ON jobs(state, started_at) WHERE state = 'active';\n\n-- Group operations: fast lookup by group_id\nCREATE INDEX IF NOT EXISTS idx_jobs_group_id\n ON jobs(group_id) WHERE group_id IS NOT NULL;\n\n-- Pending jobs: compound index for priority-ordered retrieval\nCREATE INDEX IF NOT EXISTS idx_jobs_pending_priority\n ON jobs(queue, state, priority DESC, run_at ASC) WHERE state IN ('waiting', 'delayed');\n\n-- Completed jobs: index for recovery ordering (issue #84)\nCREATE INDEX IF NOT EXISTS idx_jobs_completed_order\n ON jobs(completed_at DESC) WHERE state = 'completed';\n\n-- Cron jobs (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS cron_jobs (\n name TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n schedule TEXT,\n repeat_every INTEGER,\n priority INTEGER NOT NULL DEFAULT 0,\n next_run INTEGER NOT NULL,\n executions INTEGER NOT NULL DEFAULT 0,\n max_limit INTEGER,\n timezone TEXT,\n unique_key TEXT,\n dedup BLOB,\n skip_missed_on_restart INTEGER NOT NULL DEFAULT 0,\n skip_if_no_worker INTEGER NOT NULL DEFAULT 0,\n prevent_overlap INTEGER NOT NULL DEFAULT 1,\n job_options BLOB\n);\n\n-- Queue state persistence (optional)\nCREATE TABLE IF NOT EXISTS queue_state (\n name TEXT PRIMARY KEY,\n paused INTEGER NOT NULL DEFAULT 0,\n rate_limit INTEGER,\n concurrency_limit INTEGER\n);\n";
|
|
7
|
+
export declare const SCHEMA = "\n-- Jobs table (using UUIDv7 for job IDs)\n-- Uses BLOB for data fields (MessagePack serialization for ~2-3x faster than JSON)\nCREATE TABLE IF NOT EXISTS jobs (\n id TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n priority INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL,\n run_at INTEGER NOT NULL,\n started_at INTEGER,\n completed_at INTEGER,\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n backoff INTEGER NOT NULL DEFAULT 1000,\n ttl INTEGER,\n timeout INTEGER,\n unique_key TEXT,\n custom_id TEXT,\n depends_on BLOB,\n parent_id TEXT,\n children_ids BLOB,\n tags BLOB,\n state TEXT NOT NULL DEFAULT 'waiting',\n lifo INTEGER NOT NULL DEFAULT 0,\n group_id TEXT,\n progress INTEGER DEFAULT 0,\n progress_msg TEXT,\n remove_on_complete INTEGER DEFAULT 0,\n remove_on_fail INTEGER DEFAULT 0,\n stall_timeout INTEGER,\n last_heartbeat INTEGER,\n timeline BLOB,\n stacktrace BLOB\n);\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state\n ON jobs(queue, state);\nCREATE INDEX IF NOT EXISTS idx_jobs_run_at\n ON jobs(run_at) WHERE state IN ('waiting', 'delayed');\nCREATE INDEX IF NOT EXISTS idx_jobs_unique\n ON jobs(queue, unique_key) WHERE unique_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_custom_id\n ON jobs(custom_id) WHERE custom_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_parent\n ON jobs(parent_id) WHERE parent_id IS NOT NULL;\n\n-- Job results storage (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS job_results (\n job_id TEXT PRIMARY KEY,\n result BLOB,\n completed_at INTEGER NOT NULL\n);\n\n-- Dead letter queue (BLOB for MessagePack - stores full DlqEntry)\nCREATE TABLE IF NOT EXISTS dlq (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n job_id TEXT NOT NULL,\n queue TEXT NOT NULL,\n entry BLOB NOT NULL,\n entered_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_dlq_queue ON dlq(queue);\nCREATE INDEX IF NOT EXISTS idx_dlq_job_id ON dlq(job_id);\nCREATE INDEX IF NOT EXISTS idx_dlq_entered_at ON dlq(entered_at);\n\n-- Performance indexes for high-throughput operations\n-- Stall detection: runs every 5s, needs fast lookup of active jobs by started_at\nCREATE INDEX IF NOT EXISTS idx_jobs_state_started\n ON jobs(state, started_at) WHERE state = 'active';\n\n-- Group operations: fast lookup by group_id\nCREATE INDEX IF NOT EXISTS idx_jobs_group_id\n ON jobs(group_id) WHERE group_id IS NOT NULL;\n\n-- Pending jobs: compound index for priority-ordered retrieval\nCREATE INDEX IF NOT EXISTS idx_jobs_pending_priority\n ON jobs(queue, state, priority DESC, run_at ASC) WHERE state IN ('waiting', 'delayed');\n\n-- Completed jobs: index for recovery ordering (issue #84)\nCREATE INDEX IF NOT EXISTS idx_jobs_completed_order\n ON jobs(completed_at DESC) WHERE state = 'completed';\n\n-- Cron jobs (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS cron_jobs (\n name TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n schedule TEXT,\n repeat_every INTEGER,\n priority INTEGER NOT NULL DEFAULT 0,\n next_run INTEGER NOT NULL,\n executions INTEGER NOT NULL DEFAULT 0,\n max_limit INTEGER,\n timezone TEXT,\n unique_key TEXT,\n dedup BLOB,\n skip_missed_on_restart INTEGER NOT NULL DEFAULT 0,\n skip_if_no_worker INTEGER NOT NULL DEFAULT 0,\n prevent_overlap INTEGER NOT NULL DEFAULT 1,\n job_options BLOB\n);\n\n-- Queue state persistence (optional)\nCREATE TABLE IF NOT EXISTS queue_state (\n name TEXT PRIMARY KEY,\n paused INTEGER NOT NULL DEFAULT 0,\n rate_limit INTEGER,\n concurrency_limit INTEGER\n);\n";
|
|
8
8
|
/** Migration version table */
|
|
9
9
|
export declare const MIGRATION_TABLE = "\nCREATE TABLE IF NOT EXISTS migrations (\n version INTEGER PRIMARY KEY,\n applied_at INTEGER NOT NULL\n);\n";
|
|
10
10
|
/** Current schema version */
|
|
11
|
-
export declare const SCHEMA_VERSION =
|
|
11
|
+
export declare const SCHEMA_VERSION = 13;
|
|
12
12
|
/** All migrations in order */
|
|
13
13
|
export declare const MIGRATIONS: Record<number, string>;
|
|
@@ -44,7 +44,8 @@ CREATE TABLE IF NOT EXISTS jobs (
|
|
|
44
44
|
remove_on_fail INTEGER DEFAULT 0,
|
|
45
45
|
stall_timeout INTEGER,
|
|
46
46
|
last_heartbeat INTEGER,
|
|
47
|
-
timeline BLOB
|
|
47
|
+
timeline BLOB,
|
|
48
|
+
stacktrace BLOB
|
|
48
49
|
);
|
|
49
50
|
|
|
50
51
|
-- Indexes for common queries
|
|
@@ -132,7 +133,7 @@ CREATE TABLE IF NOT EXISTS migrations (
|
|
|
132
133
|
);
|
|
133
134
|
`;
|
|
134
135
|
/** Current schema version */
|
|
135
|
-
export const SCHEMA_VERSION =
|
|
136
|
+
export const SCHEMA_VERSION = 13;
|
|
136
137
|
/** All migrations in order */
|
|
137
138
|
export const MIGRATIONS = {
|
|
138
139
|
1: SCHEMA,
|
|
@@ -182,5 +183,9 @@ CREATE INDEX IF NOT EXISTS idx_jobs_completed_order
|
|
|
182
183
|
// Migration 12: Add per-cron job options (retry/cleanup policy, issue #86)
|
|
183
184
|
12: `
|
|
184
185
|
ALTER TABLE cron_jobs ADD COLUMN job_options BLOB;
|
|
186
|
+
`,
|
|
187
|
+
// Migration 13: Persist the last failure's stacktrace on jobs (issue #74)
|
|
188
|
+
13: `
|
|
189
|
+
ALTER TABLE jobs ADD COLUMN stacktrace BLOB;
|
|
185
190
|
`,
|
|
186
191
|
};
|
|
@@ -332,8 +332,8 @@ export class SqliteStorage {
|
|
|
332
332
|
updateForRetry(job) {
|
|
333
333
|
this.safeWrite(() => {
|
|
334
334
|
this.db
|
|
335
|
-
.prepare('UPDATE jobs SET attempts = ?, run_at = ?, state = ?, timeline = ? WHERE id = ?')
|
|
336
|
-
.run(job.attempts, job.runAt, 'waiting', job.timeline.length > 0 ? pack(job.timeline) : null, job.id);
|
|
335
|
+
.prepare('UPDATE jobs SET attempts = ?, run_at = ?, state = ?, timeline = ?, stacktrace = ? WHERE id = ?')
|
|
336
|
+
.run(job.attempts, job.runAt, 'waiting', job.timeline.length > 0 ? pack(job.timeline) : null, job.stacktrace && job.stacktrace.length > 0 ? pack(job.stacktrace) : null, job.id);
|
|
337
337
|
});
|
|
338
338
|
}
|
|
339
339
|
deleteJob(jobId) {
|
|
@@ -121,6 +121,9 @@ export function rowToJob(row) {
|
|
|
121
121
|
timeline: row.timeline
|
|
122
122
|
? unpack(row.timeline, [], `${jobContext}:timeline`)
|
|
123
123
|
: [],
|
|
124
|
+
stacktrace: row.stacktrace
|
|
125
|
+
? unpack(row.stacktrace, null, `${jobContext}:stacktrace`)
|
|
126
|
+
: null,
|
|
124
127
|
};
|
|
125
128
|
// Stamp a collision-proof corruption marker (non-enumerable Symbol, never
|
|
126
129
|
// persisted) so the recovery path routes this job to the DLQ rather than
|
|
@@ -157,6 +160,8 @@ export function reconstructDlqEntry(entry) {
|
|
|
157
160
|
dependsOn: entry.job.dependsOn.map((id) => brandId(id)),
|
|
158
161
|
parentId: entry.job.parentId !== null ? brandId(entry.job.parentId) : null,
|
|
159
162
|
childrenIds: entry.job.childrenIds.map((id) => brandId(id)),
|
|
163
|
+
// Pre-#74 blobs have no stacktrace field — restore the domain invariant
|
|
164
|
+
stacktrace: entry.job.stacktrace ?? null,
|
|
160
165
|
},
|
|
161
166
|
};
|
|
162
167
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server bootstrap — the ONE place that boots a full bunqueue server.
|
|
3
|
+
* Used by both entry points (`bunqueue` bare via main.ts and
|
|
4
|
+
* `bunqueue start` via the CLI) so they cannot drift: S3 backup, cloud
|
|
5
|
+
* agent, stats interval, crash handlers and graceful shutdown are always on.
|
|
6
|
+
*/
|
|
7
|
+
import { type BunqueueConfig, type ResolvedConfig } from '../../config';
|
|
8
|
+
/** Boot the full server from resolved configuration. Runs until shutdown. */
|
|
9
|
+
export declare function bootServer(fileConfig: BunqueueConfig | null, config: ResolvedConfig): void;
|