bunqueue 2.8.8 → 2.8.10
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/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/forwarder.d.ts +72 -0
- package/dist/client/forwarder.js +79 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +1 -0
- package/dist/client/queue/queue.d.ts +9 -0
- package/dist/client/queue/queue.js +19 -0
- package/dist/domain/types/cron.js +3 -1
- package/dist/domain/types/webhook.d.ts +12 -2
- package/dist/domain/types/webhook.js +13 -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/monitoring.js +7 -0
- package/dist/main.js +12 -224
- package/dist/mcp/tools/webhookTools.js +2 -10
- package/package.json +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forwarder — store-and-forward from a local (edge) queue to a remote
|
|
3
|
+
* bunqueue server.
|
|
4
|
+
*
|
|
5
|
+
* The edge pattern: jobs buffer in the local queue (typically embedded
|
|
6
|
+
* SQLite on a gateway); the Forwarder drains them to a central server over
|
|
7
|
+
* TCP/TLS. A remote failure makes the local job fail → local retry/backoff →
|
|
8
|
+
* local DLQ, so nothing is lost while the uplink is down.
|
|
9
|
+
*
|
|
10
|
+
* Idempotency: every forwarded job carries the deterministic remote jobId
|
|
11
|
+
* `fwd:<localQueueKey>:<localJobId>`. The server dedupes custom jobIds, so a
|
|
12
|
+
* re-forward after a crash or retry never duplicates the job remotely.
|
|
13
|
+
*/
|
|
14
|
+
import { EventEmitter } from 'events';
|
|
15
|
+
import type { ConnectionOptions, QueueOptions } from './types';
|
|
16
|
+
/** Options for queue.forward() */
|
|
17
|
+
export interface ForwardOptions {
|
|
18
|
+
/** Remote server connection (host/port/tls/token...) */
|
|
19
|
+
to: ConnectionOptions;
|
|
20
|
+
/** Remote queue name (default: same as the source queue) */
|
|
21
|
+
queue?: string;
|
|
22
|
+
/** Parallel forwards (default: 4) */
|
|
23
|
+
concurrency?: number;
|
|
24
|
+
/** Push to the remote with durable: true (immediate fsync server-side) */
|
|
25
|
+
durable?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** Source queue identity + config, provided by Queue.forward() */
|
|
28
|
+
export interface ForwardSource {
|
|
29
|
+
/** Logical source queue name */
|
|
30
|
+
name: string;
|
|
31
|
+
/** Source queue key (prefixKey + name) — used in the deterministic jobId */
|
|
32
|
+
queueKey: string;
|
|
33
|
+
prefixKey?: string;
|
|
34
|
+
embedded: boolean;
|
|
35
|
+
dataPath?: string;
|
|
36
|
+
connection?: ConnectionOptions;
|
|
37
|
+
}
|
|
38
|
+
/** Minimal remote-queue surface the Forwarder needs (avoids a Queue import cycle) */
|
|
39
|
+
interface RemoteQueueLike {
|
|
40
|
+
add(name: string, data: unknown, opts?: {
|
|
41
|
+
jobId?: string;
|
|
42
|
+
priority?: number;
|
|
43
|
+
durable?: boolean;
|
|
44
|
+
}): Promise<unknown>;
|
|
45
|
+
close(): Promise<void> | void;
|
|
46
|
+
}
|
|
47
|
+
/** Constructor for the remote queue, injected by Queue.forward() */
|
|
48
|
+
export type RemoteQueueCtor = new (name: string, opts: QueueOptions) => RemoteQueueLike;
|
|
49
|
+
/** Info emitted with each 'forwarded' event */
|
|
50
|
+
export interface ForwardedInfo {
|
|
51
|
+
/** Local job id */
|
|
52
|
+
id: string;
|
|
53
|
+
/** Deterministic remote job id (fwd:<queueKey>:<id>) */
|
|
54
|
+
remoteId: string;
|
|
55
|
+
/** Job name */
|
|
56
|
+
name: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Drains a local queue into a remote bunqueue server.
|
|
60
|
+
* Create via `queue.forward(options)`; stop with `close()`.
|
|
61
|
+
*/
|
|
62
|
+
export declare class Forwarder extends EventEmitter {
|
|
63
|
+
on(event: 'forwarded', listener: (info: ForwardedInfo) => void): this;
|
|
64
|
+
on(event: 'error', listener: (error: Error) => void): this;
|
|
65
|
+
private readonly remote;
|
|
66
|
+
private readonly worker;
|
|
67
|
+
private closed;
|
|
68
|
+
constructor(source: ForwardSource, options: ForwardOptions, RemoteQueue: RemoteQueueCtor);
|
|
69
|
+
/** Stop forwarding and release connections. Safe to call more than once. */
|
|
70
|
+
close(): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forwarder — store-and-forward from a local (edge) queue to a remote
|
|
3
|
+
* bunqueue server.
|
|
4
|
+
*
|
|
5
|
+
* The edge pattern: jobs buffer in the local queue (typically embedded
|
|
6
|
+
* SQLite on a gateway); the Forwarder drains them to a central server over
|
|
7
|
+
* TCP/TLS. A remote failure makes the local job fail → local retry/backoff →
|
|
8
|
+
* local DLQ, so nothing is lost while the uplink is down.
|
|
9
|
+
*
|
|
10
|
+
* Idempotency: every forwarded job carries the deterministic remote jobId
|
|
11
|
+
* `fwd:<localQueueKey>:<localJobId>`. The server dedupes custom jobIds, so a
|
|
12
|
+
* re-forward after a crash or retry never duplicates the job remotely.
|
|
13
|
+
*/
|
|
14
|
+
import { EventEmitter } from 'events';
|
|
15
|
+
import { Worker } from './worker';
|
|
16
|
+
/**
|
|
17
|
+
* Drains a local queue into a remote bunqueue server.
|
|
18
|
+
* Create via `queue.forward(options)`; stop with `close()`.
|
|
19
|
+
*/
|
|
20
|
+
export class Forwarder extends EventEmitter {
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
on(event, listener) {
|
|
23
|
+
return super.on(event, listener);
|
|
24
|
+
}
|
|
25
|
+
remote;
|
|
26
|
+
worker;
|
|
27
|
+
closed = false;
|
|
28
|
+
constructor(source, options, RemoteQueue) {
|
|
29
|
+
super();
|
|
30
|
+
this.remote = new RemoteQueue(options.queue ?? source.name, {
|
|
31
|
+
embedded: false,
|
|
32
|
+
connection: options.to,
|
|
33
|
+
autoBatch: { enabled: false },
|
|
34
|
+
});
|
|
35
|
+
this.worker = new Worker(source.name, async (job) => {
|
|
36
|
+
const remoteId = `fwd:${source.queueKey}:${job.id}`;
|
|
37
|
+
await this.remote.add(job.name, job.data, {
|
|
38
|
+
jobId: remoteId,
|
|
39
|
+
...(job.opts.priority !== undefined && { priority: job.opts.priority }),
|
|
40
|
+
...(options.durable && { durable: true }),
|
|
41
|
+
});
|
|
42
|
+
const info = { id: job.id, remoteId, name: job.name };
|
|
43
|
+
// A throwing user listener must not fail a forward that already
|
|
44
|
+
// succeeded remotely (it would trigger a local retry of a done job).
|
|
45
|
+
try {
|
|
46
|
+
this.emit('forwarded', info);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* listener error — forward itself succeeded */
|
|
50
|
+
}
|
|
51
|
+
return info;
|
|
52
|
+
}, {
|
|
53
|
+
embedded: source.embedded,
|
|
54
|
+
dataPath: source.dataPath,
|
|
55
|
+
connection: source.connection,
|
|
56
|
+
prefixKey: source.prefixKey,
|
|
57
|
+
concurrency: options.concurrency ?? 4,
|
|
58
|
+
});
|
|
59
|
+
// A remote failure is already handled by the local retry/DLQ path; the
|
|
60
|
+
// event is observability. Guard: EventEmitter throws on 'error' without
|
|
61
|
+
// listeners, and a transient uplink failure must never crash the process.
|
|
62
|
+
this.worker.on('failed', (_job, err) => {
|
|
63
|
+
if (this.listenerCount('error') > 0)
|
|
64
|
+
this.emit('error', err);
|
|
65
|
+
});
|
|
66
|
+
this.worker.on('error', (err) => {
|
|
67
|
+
if (this.listenerCount('error') > 0)
|
|
68
|
+
this.emit('error', err);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Stop forwarding and release connections. Safe to call more than once. */
|
|
72
|
+
async close() {
|
|
73
|
+
if (this.closed)
|
|
74
|
+
return;
|
|
75
|
+
this.closed = true;
|
|
76
|
+
await this.worker.close();
|
|
77
|
+
await this.remote.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export { Worker } from './worker';
|
|
|
26
26
|
export { Bunqueue } from './bunqueue';
|
|
27
27
|
export type { BunqueueOptions, BunqueueMiddleware, RetryStrategy, RetryConfig, CircuitBreakerConfig, TriggerRule, PriorityAgingConfig, BatchProcessor, BatchConfig, JobTtlConfig, BunqueueDeduplicationConfig, BunqueueDebounceConfig, BunqueueDlqConfig, } from './bunqueue';
|
|
28
28
|
export { SandboxedWorker } from './sandboxedWorker';
|
|
29
|
+
export { Forwarder } from './forwarder';
|
|
30
|
+
export type { ForwardOptions, ForwardedInfo } from './forwarder';
|
|
29
31
|
export { QueueEvents } from './events';
|
|
30
32
|
export { QueueGroup } from './queueGroup';
|
|
31
33
|
export { FlowProducer } from './flow';
|
package/dist/client/index.js
CHANGED
|
@@ -25,6 +25,7 @@ export { Queue } from './queue';
|
|
|
25
25
|
export { Worker } from './worker';
|
|
26
26
|
export { Bunqueue } from './bunqueue';
|
|
27
27
|
export { SandboxedWorker } from './sandboxedWorker';
|
|
28
|
+
export { Forwarder } from './forwarder';
|
|
28
29
|
export { QueueEvents } from './events';
|
|
29
30
|
export { QueueGroup } from './queueGroup';
|
|
30
31
|
export { FlowProducer } from './flow';
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* BullMQ-style queue for job management
|
|
4
4
|
*/
|
|
5
5
|
import type { Job, JobOptions, QueueOptions, StallConfig, DlqConfig, DlqEntry, DlqStats, DlqFilter, ChangePriorityOpts, GetDependenciesOpts, JobDependencies, JobDependenciesCount, JobStateType } from '../types';
|
|
6
|
+
import { Forwarder, type ForwardOptions } from '../forwarder';
|
|
6
7
|
import * as countsOps from './operations/counts';
|
|
7
8
|
import * as schedulerOps from './scheduler';
|
|
8
9
|
import type { RepeatOpts, JobTemplate, SchedulerInfo } from './scheduler';
|
|
@@ -168,6 +169,14 @@ export declare class Queue<T = unknown> {
|
|
|
168
169
|
};
|
|
169
170
|
data: number[];
|
|
170
171
|
}>;
|
|
172
|
+
/**
|
|
173
|
+
* Drain this queue's jobs to a remote bunqueue server (store-and-forward).
|
|
174
|
+
* Typical edge pattern: embedded local queue as offline buffer, forwarded
|
|
175
|
+
* to a central server over TCP/TLS. Remote failures leave jobs local
|
|
176
|
+
* (retry → DLQ) — nothing is lost while the uplink is down. Forwarded jobs
|
|
177
|
+
* carry a deterministic remote jobId so re-forwards never duplicate.
|
|
178
|
+
*/
|
|
179
|
+
forward(options: ForwardOptions): Forwarder;
|
|
171
180
|
disconnect(): Promise<void>;
|
|
172
181
|
close(): void;
|
|
173
182
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { TcpConnectionPool, getSharedPool, releaseSharedPool } from '../tcpPool';
|
|
6
6
|
import { FORCE_EMBEDDED } from './helpers';
|
|
7
7
|
import { AddBatcher } from './addBatcher';
|
|
8
|
+
import { Forwarder } from '../forwarder';
|
|
8
9
|
import { resolveToken } from '../resolveToken';
|
|
9
10
|
import { getSharedManager } from '../manager';
|
|
10
11
|
// Import operation modules
|
|
@@ -483,6 +484,24 @@ export class Queue {
|
|
|
483
484
|
getMetrics(type, start, end) {
|
|
484
485
|
return workersOps.getMetrics(this.ctx, type, start, end);
|
|
485
486
|
}
|
|
487
|
+
// ============ Forwarding ============
|
|
488
|
+
/**
|
|
489
|
+
* Drain this queue's jobs to a remote bunqueue server (store-and-forward).
|
|
490
|
+
* Typical edge pattern: embedded local queue as offline buffer, forwarded
|
|
491
|
+
* to a central server over TCP/TLS. Remote failures leave jobs local
|
|
492
|
+
* (retry → DLQ) — nothing is lost while the uplink is down. Forwarded jobs
|
|
493
|
+
* carry a deterministic remote jobId so re-forwards never duplicate.
|
|
494
|
+
*/
|
|
495
|
+
forward(options) {
|
|
496
|
+
return new Forwarder({
|
|
497
|
+
name: this.name,
|
|
498
|
+
queueKey: this.queueKey,
|
|
499
|
+
prefixKey: this.prefixKey || undefined,
|
|
500
|
+
embedded: this.embedded,
|
|
501
|
+
dataPath: this.opts.dataPath,
|
|
502
|
+
connection: this.opts.connection,
|
|
503
|
+
}, options, Queue);
|
|
504
|
+
}
|
|
486
505
|
// ============ Connection ============
|
|
487
506
|
async disconnect() {
|
|
488
507
|
if (this.addBatcher) {
|
|
@@ -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,
|
|
@@ -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 {
|
|
@@ -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;
|
|
@@ -0,0 +1,217 @@
|
|
|
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 { QueueManager } from '../../application/queueManager';
|
|
8
|
+
import { createTcpServer } from './tcp';
|
|
9
|
+
import { createHttpServer } from './http';
|
|
10
|
+
import { Logger, serverLog, statsLog } from '../../shared/logger';
|
|
11
|
+
import { stopRateLimiter } from './rateLimiter';
|
|
12
|
+
import { VERSION } from '../../shared/version';
|
|
13
|
+
import { S3BackupManager } from '../backup';
|
|
14
|
+
import { CloudAgent } from '../cloud';
|
|
15
|
+
import { SHARD_COUNT } from '../../shared/hash';
|
|
16
|
+
import { resolveCloudConfig, resolveBackupConfig, resolveTlsServerOptions, } from '../../config';
|
|
17
|
+
/** Print startup banner */
|
|
18
|
+
function printBanner(config, cloudUrl) {
|
|
19
|
+
const dim = '\x1b[2m';
|
|
20
|
+
const reset = '\x1b[0m';
|
|
21
|
+
const bold = '\x1b[1m';
|
|
22
|
+
const magenta = '\x1b[35m';
|
|
23
|
+
const green = '\x1b[32m';
|
|
24
|
+
const yellow = '\x1b[33m';
|
|
25
|
+
// Format TCP endpoint display
|
|
26
|
+
const tcpDisplay = config.tcpSocketPath
|
|
27
|
+
? `${bold}${config.tcpSocketPath}${reset} ${dim}(unix)${reset}`
|
|
28
|
+
: `${bold}${config.hostname}:${config.tcpPort}${reset}`;
|
|
29
|
+
// Format HTTP endpoint display
|
|
30
|
+
const httpDisplay = config.httpSocketPath
|
|
31
|
+
? `${bold}${config.httpSocketPath}${reset} ${dim}(unix)${reset}`
|
|
32
|
+
: `${bold}${config.hostname}:${config.httpPort}${reset}`;
|
|
33
|
+
// Socket mode display
|
|
34
|
+
const hasUnixSockets = config.tcpSocketPath !== undefined || config.httpSocketPath !== undefined;
|
|
35
|
+
const socketDisplay = hasUnixSockets
|
|
36
|
+
? `${green}enabled${reset} ${dim}(${config.tcpSocketPath ? 'TCP' : ''}${config.tcpSocketPath && config.httpSocketPath ? '+' : ''}${config.httpSocketPath ? 'HTTP' : ''})${reset}`
|
|
37
|
+
: `${dim}disabled${reset}`;
|
|
38
|
+
console.log(`
|
|
39
|
+
${magenta} (\\(\\ ${reset}
|
|
40
|
+
${magenta} ( -.-) ${bold}bunqueue${reset} ${dim}v${VERSION}${reset}
|
|
41
|
+
${magenta} o_(")(") ${reset}${dim}High-performance job queue for Bun${reset}
|
|
42
|
+
|
|
43
|
+
${dim}─────────────────────────────────────────────────${reset}
|
|
44
|
+
|
|
45
|
+
${green}●${reset} TCP ${tcpDisplay}
|
|
46
|
+
${green}●${reset} HTTP ${httpDisplay}
|
|
47
|
+
${yellow}●${reset} Socket ${socketDisplay}
|
|
48
|
+
${yellow}●${reset} Data ${config.dataPath ?? 'in-memory'}
|
|
49
|
+
${yellow}●${reset} TLS ${config.tlsCertFile ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
|
|
50
|
+
${yellow}●${reset} Auth ${config.authTokens.length > 0 ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
|
|
51
|
+
${yellow}●${reset} S3 Backup ${config.s3BackupEnabled ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
|
|
52
|
+
${yellow}●${reset} Cloud ${cloudUrl ? `${green}enabled${reset} ${dim}→ ${cloudUrl}${reset}` : `${dim}disabled${reset}`}
|
|
53
|
+
${dim}●${reset} Shards ${bold}${SHARD_COUNT}${reset} ${dim}(${navigator.hardwareConcurrency} CPU cores)${reset}
|
|
54
|
+
|
|
55
|
+
${dim}─────────────────────────────────────────────────${reset}
|
|
56
|
+
|
|
57
|
+
`);
|
|
58
|
+
}
|
|
59
|
+
/** Boot the full server from resolved configuration. Runs until shutdown. */
|
|
60
|
+
export function bootServer(fileConfig, config) {
|
|
61
|
+
// Apply logging config before anything else
|
|
62
|
+
const logFormat = fileConfig?.logging?.format ?? Bun.env.LOG_FORMAT;
|
|
63
|
+
const logLevel = fileConfig?.logging?.level ?? Bun.env.LOG_LEVEL?.toLowerCase();
|
|
64
|
+
if (logFormat === 'json')
|
|
65
|
+
Logger.enableJsonMode();
|
|
66
|
+
if (logLevel) {
|
|
67
|
+
const validLevels = ['debug', 'info', 'warn', 'error'];
|
|
68
|
+
if (validLevels.includes(logLevel))
|
|
69
|
+
Logger.setLevel(logLevel);
|
|
70
|
+
}
|
|
71
|
+
// Resolve cloud config
|
|
72
|
+
const cloudConfig = resolveCloudConfig(fileConfig, config.dataPath);
|
|
73
|
+
// Resolve TLS config — fail fast on partial cert/key before binding anything
|
|
74
|
+
let tlsConfig;
|
|
75
|
+
try {
|
|
76
|
+
tlsConfig = resolveTlsServerOptions(config);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
serverLog.error(err instanceof Error ? err.message : String(err));
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
printBanner(config, cloudConfig?.url);
|
|
83
|
+
// Create queue manager
|
|
84
|
+
const queueManager = new QueueManager({
|
|
85
|
+
dataPath: config.dataPath,
|
|
86
|
+
});
|
|
87
|
+
// Start TCP + HTTP servers; a bind failure must not leave a half-started process
|
|
88
|
+
let tcpServer;
|
|
89
|
+
let httpServer;
|
|
90
|
+
try {
|
|
91
|
+
tcpServer = createTcpServer(queueManager, {
|
|
92
|
+
port: config.tcpPort,
|
|
93
|
+
hostname: config.hostname,
|
|
94
|
+
authTokens: config.authTokens,
|
|
95
|
+
...(tlsConfig && { tls: tlsConfig }),
|
|
96
|
+
});
|
|
97
|
+
httpServer = createHttpServer(queueManager, {
|
|
98
|
+
port: config.httpPort,
|
|
99
|
+
hostname: config.hostname,
|
|
100
|
+
socketPath: config.httpSocketPath,
|
|
101
|
+
authTokens: config.authTokens,
|
|
102
|
+
corsOrigins: config.corsOrigins,
|
|
103
|
+
requireAuthForMetrics: config.requireAuthForMetrics,
|
|
104
|
+
...(tlsConfig && { tls: tlsConfig }),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
109
|
+
console.error(`Failed to start server: ${msg}`);
|
|
110
|
+
queueManager.shutdown();
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
// Initialize S3 backup manager
|
|
114
|
+
let backupManager = null;
|
|
115
|
+
if (config.dataPath) {
|
|
116
|
+
const backupConfig = resolveBackupConfig(fileConfig, config.dataPath);
|
|
117
|
+
backupManager = new S3BackupManager(backupConfig);
|
|
118
|
+
backupManager.setDashboardEmit(queueManager.emitDashboardEvent.bind(queueManager));
|
|
119
|
+
backupManager.start();
|
|
120
|
+
}
|
|
121
|
+
// Initialize bunqueue Cloud agent (remote dashboard telemetry)
|
|
122
|
+
const cloudAgent = cloudConfig ? CloudAgent.createFromConfig(queueManager, cloudConfig) : null;
|
|
123
|
+
if (cloudAgent) {
|
|
124
|
+
cloudAgent.setServerHandles({
|
|
125
|
+
getConnectionCount: () => tcpServer.getConnectionCount(),
|
|
126
|
+
getWsClientCount: () => httpServer.getWsClientCount(),
|
|
127
|
+
getSseClientCount: () => httpServer.getSseClientCount(),
|
|
128
|
+
getBackupStatus: () => backupManager?.getStatus() ?? null,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
queueManager.emitDashboardEvent('server:started', {
|
|
132
|
+
tcpPort: config.tcpPort,
|
|
133
|
+
httpPort: config.httpPort,
|
|
134
|
+
shards: SHARD_COUNT,
|
|
135
|
+
});
|
|
136
|
+
// Graceful shutdown
|
|
137
|
+
let shuttingDown = false;
|
|
138
|
+
const shutdown = async (signal) => {
|
|
139
|
+
if (shuttingDown)
|
|
140
|
+
return;
|
|
141
|
+
shuttingDown = true;
|
|
142
|
+
serverLog.info(`Received ${signal}, shutting down...`);
|
|
143
|
+
// Stop stats interval immediately
|
|
144
|
+
clearInterval(statsInterval);
|
|
145
|
+
tcpServer.stop();
|
|
146
|
+
httpServer.stop();
|
|
147
|
+
const shutdownTimeout = config.shutdownTimeoutMs;
|
|
148
|
+
const start = Date.now();
|
|
149
|
+
while (Date.now() - start < shutdownTimeout) {
|
|
150
|
+
const stats = queueManager.getStats();
|
|
151
|
+
if (stats.active === 0)
|
|
152
|
+
break;
|
|
153
|
+
serverLog.info(`Waiting for ${stats.active} active jobs...`);
|
|
154
|
+
await Bun.sleep(1000);
|
|
155
|
+
}
|
|
156
|
+
// Stop backup manager
|
|
157
|
+
if (backupManager) {
|
|
158
|
+
backupManager.stop();
|
|
159
|
+
}
|
|
160
|
+
// Stop Cloud agent (sends final shutdown snapshot)
|
|
161
|
+
if (cloudAgent) {
|
|
162
|
+
await cloudAgent.stop();
|
|
163
|
+
}
|
|
164
|
+
queueManager.emitDashboardEvent('server:shutdown', { signal });
|
|
165
|
+
queueManager.shutdown();
|
|
166
|
+
stopRateLimiter();
|
|
167
|
+
serverLog.info('Shutdown complete');
|
|
168
|
+
process.exit(0);
|
|
169
|
+
};
|
|
170
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
171
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
172
|
+
process.on('uncaughtException', (err) => {
|
|
173
|
+
serverLog.error('Uncaught exception - initiating shutdown', {
|
|
174
|
+
error: err.message,
|
|
175
|
+
stack: err.stack,
|
|
176
|
+
});
|
|
177
|
+
void shutdown('uncaughtException');
|
|
178
|
+
});
|
|
179
|
+
process.on('unhandledRejection', (reason) => {
|
|
180
|
+
serverLog.error('Unhandled promise rejection - initiating shutdown', {
|
|
181
|
+
reason: reason instanceof Error ? reason.message : String(reason),
|
|
182
|
+
stack: reason instanceof Error ? reason.stack : undefined,
|
|
183
|
+
});
|
|
184
|
+
void shutdown('unhandledRejection');
|
|
185
|
+
});
|
|
186
|
+
// Print stats periodically
|
|
187
|
+
const statsInterval = setInterval(() => {
|
|
188
|
+
const stats = queueManager.getStats();
|
|
189
|
+
const memStats = queueManager.getMemoryStats();
|
|
190
|
+
const workerStats = queueManager.workerManager.getStats();
|
|
191
|
+
const mem = process.memoryUsage();
|
|
192
|
+
const now = new Date();
|
|
193
|
+
const timestamp = now.toLocaleTimeString('en-GB', {
|
|
194
|
+
hour: '2-digit',
|
|
195
|
+
minute: '2-digit',
|
|
196
|
+
second: '2-digit',
|
|
197
|
+
});
|
|
198
|
+
statsLog.info('Queue statistics', {
|
|
199
|
+
time: timestamp,
|
|
200
|
+
waiting: stats.waiting,
|
|
201
|
+
active: stats.active,
|
|
202
|
+
delayed: stats.delayed,
|
|
203
|
+
completed: stats.completed,
|
|
204
|
+
dlq: stats.dlq,
|
|
205
|
+
tcp: tcpServer.getConnectionCount(),
|
|
206
|
+
ws: httpServer.getWsClientCount(),
|
|
207
|
+
sse: httpServer.getSseClientCount(),
|
|
208
|
+
workers: `${workerStats.active}/${workerStats.total}`,
|
|
209
|
+
mem: `${Math.round(mem.heapUsed / 1024 / 1024)}MB/${Math.round(mem.heapTotal / 1024 / 1024)}MB`,
|
|
210
|
+
rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
|
|
211
|
+
// Internal collection sizes (for memory debugging)
|
|
212
|
+
idx: memStats.jobIndex,
|
|
213
|
+
locks: memStats.jobLocks,
|
|
214
|
+
clients: memStats.clientJobsTotal,
|
|
215
|
+
});
|
|
216
|
+
}, config.statsIntervalMs);
|
|
217
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as resp from '../../../domain/types/response';
|
|
6
6
|
import { jobId } from '../../../domain/types/job';
|
|
7
|
+
import { validateNumericField } from '../protocol';
|
|
7
8
|
/**
|
|
8
9
|
* Coerce a value to a finite number, or return undefined if it can't be.
|
|
9
10
|
* Guards config endpoints against non-numeric input (e.g. `"abc"`) that would
|
|
@@ -108,6 +109,13 @@ export async function handleDiscard(cmd, ctx, reqId) {
|
|
|
108
109
|
/** Handle WaitJob command - wait for job completion (event-driven, no polling) */
|
|
109
110
|
export async function handleWaitJob(cmd, ctx, reqId) {
|
|
110
111
|
const jid = jobId(cmd.id);
|
|
112
|
+
// Bounded like PULL (which caps at 60s); waiting for completion is
|
|
113
|
+
// legitimately longer, so the cap here is 10 min. validateNumericField also
|
|
114
|
+
// rejects NaN/Infinity from hostile clients (a hand-rolled <0/>max check
|
|
115
|
+
// would let NaN through and resolve instantly as a false "not completed").
|
|
116
|
+
const timeoutError = validateNumericField(cmd.timeout, 'timeout', { min: 0, max: 600000 });
|
|
117
|
+
if (timeoutError)
|
|
118
|
+
return resp.error(timeoutError, reqId);
|
|
111
119
|
const timeout = cmd.timeout ?? 30000;
|
|
112
120
|
// First check if job exists and is already completed
|
|
113
121
|
const job = await ctx.queueManager.getJob(jid);
|
|
@@ -6,6 +6,7 @@ import { jobId } from '../../../domain/types/job';
|
|
|
6
6
|
import { VERSION } from '../../../shared/version';
|
|
7
7
|
import * as resp from '../../../domain/types/response';
|
|
8
8
|
import { validateWebhookUrl } from '../protocol';
|
|
9
|
+
import { WEBHOOK_EVENTS } from '../../../domain/types/webhook';
|
|
9
10
|
// ============ Job Logs ============
|
|
10
11
|
export function handleAddLog(cmd, ctx, reqId) {
|
|
11
12
|
const jid = jobId(cmd.id);
|
|
@@ -140,6 +141,12 @@ export function handleAddWebhook(cmd, ctx, reqId) {
|
|
|
140
141
|
const urlError = validateWebhookUrl(cmd.url);
|
|
141
142
|
if (urlError)
|
|
142
143
|
return resp.error(urlError, reqId);
|
|
144
|
+
// Reject events that are never triggered — a webhook subscribed to a dead
|
|
145
|
+
// event would be created "ok" and then never fire (silent failure).
|
|
146
|
+
const invalidEvents = cmd.events.filter((e) => !WEBHOOK_EVENTS.includes(e));
|
|
147
|
+
if (invalidEvents.length > 0) {
|
|
148
|
+
return resp.error(`Invalid webhook event(s): ${invalidEvents.join(', ')}. Valid: ${WEBHOOK_EVENTS.join(', ')}`, reqId);
|
|
149
|
+
}
|
|
143
150
|
const webhook = ctx.queueManager.webhookManager.add(cmd.url, cmd.events, cmd.queue, cmd.secret);
|
|
144
151
|
ctx.queueManager.emitDashboardEvent('webhook:added', {
|
|
145
152
|
id: webhook.id,
|