bunqueue 2.8.23 → 2.8.25

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.
@@ -204,9 +204,11 @@ function cleanEmptyQueues(ctx) {
204
204
  const shard = ctx.shards[i];
205
205
  const emptyQueues = [];
206
206
  for (const [queueName, queue] of shard.queues) {
207
- const dlqEntries = shard.dlq.get(queueName);
207
+ // `shard.dlq` is a getter that rebuilds a Map of EVERY queue's DLQ entries
208
+ // on each access — calling it once per queue in this loop was O(Q²) per
209
+ // shard per tick. getDlqCount(queueName) is an O(1) counter lookup.
208
210
  if (queue.size === 0 &&
209
- (!dlqEntries || dlqEntries.length === 0) &&
211
+ shard.getDlqCount(queueName) === 0 &&
210
212
  !hasProcessingJobsForQueue(ctx, queueName) &&
211
213
  !hasWaitingDepsForQueue(ctx, queueName)) {
212
214
  emptyQueues.push(queueName);
@@ -106,6 +106,11 @@ export async function getJob(ctx, id) {
106
106
  });
107
107
  if (j.progress !== undefined)
108
108
  result.progress = j.progress;
109
+ // Mirror getJob/buildJobProperties: a number maps through, null → undefined (#104)
110
+ if (typeof j.startedAt === 'number')
111
+ result.processedOn = j.startedAt;
112
+ if (typeof j.completedAt === 'number')
113
+ result.finishedOn = j.completedAt;
109
114
  return result;
110
115
  }
111
116
  /** Get job state by ID */
@@ -160,7 +165,7 @@ export function getJobs(ctx, options = {}) {
160
165
  });
161
166
  return jobs.map((j) => {
162
167
  const name = j.data?.name ?? 'unknown';
163
- return createSimpleJob(String(j.id), name, j.data, j.createdAt, {
168
+ const result = createSimpleJob(String(j.id), name, j.data, j.createdAt, {
164
169
  queueName: ctx.name,
165
170
  embedded: ctx.embedded,
166
171
  tcp: ctx.tcp,
@@ -169,6 +174,12 @@ export function getJobs(ctx, options = {}) {
169
174
  retryJob: ctx.retryJob,
170
175
  getChildrenValues: ctx.getChildrenValues,
171
176
  });
177
+ // Mirror getJob/buildJobProperties: a number maps through, null → undefined (#104)
178
+ if (typeof j.startedAt === 'number')
179
+ result.processedOn = j.startedAt;
180
+ if (typeof j.completedAt === 'number')
181
+ result.finishedOn = j.completedAt;
182
+ return result;
172
183
  });
173
184
  }
174
185
  /** Get jobs with filtering (async, works with TCP) */
@@ -211,6 +222,11 @@ export async function getJobsAsync(ctx, options = {}) {
211
222
  result.priority = j.priority;
212
223
  if (j.attempts !== undefined)
213
224
  result.attemptsMade = j.attempts;
225
+ // Mirror getJob/buildJobProperties: a number maps through, null → undefined (#104)
226
+ if (typeof j.startedAt === 'number')
227
+ result.processedOn = j.startedAt;
228
+ if (typeof j.completedAt === 'number')
229
+ result.finishedOn = j.completedAt;
214
230
  return result;
215
231
  });
216
232
  }
@@ -41,7 +41,10 @@ export async function createConnection(target, connectTimeout, events) {
41
41
  data(_sock, data) {
42
42
  let frames;
43
43
  try {
44
- frames = socketData.frameParser.addData(new Uint8Array(data));
44
+ // `data` (Bun Buffer) is copied into addData's own buffer synchronously
45
+ // and never retained, so the `new Uint8Array(data)` wrapper was a
46
+ // redundant full copy per read on the client response path.
47
+ frames = socketData.frameParser.addData(data);
45
48
  }
46
49
  catch (err) {
47
50
  if (err instanceof FrameSizeError) {
@@ -160,19 +160,25 @@ export class FrameParser {
160
160
  * @throws {FrameSizeError} if frame length exceeds maxFrameSize
161
161
  */
162
162
  addData(data) {
163
- // Concatenate buffers
164
- const newBuffer = new Uint8Array(this.buffer.length + data.length);
165
- newBuffer.set(this.buffer);
166
- newBuffer.set(data, this.buffer.length);
167
- this.buffer = newBuffer;
163
+ // Concatenate any buffered partial frame with the new data into a single
164
+ // owned buffer (one copy). `data` is fully copied here and never retained,
165
+ // so the caller may safely reuse its read buffer after this returns.
166
+ const buffer = new Uint8Array(this.buffer.length + data.length);
167
+ buffer.set(this.buffer);
168
+ buffer.set(data, this.buffer.length);
168
169
  const frames = [];
169
- while (this.buffer.length >= 4) {
170
+ // Read cursor. Previously the tail was resliced with `buffer.slice(4 + len)`
171
+ // after EVERY frame — O(tail) per frame, i.e. O(F²) when F frames arrive
172
+ // coalesced in a single read (deep pipelining / OS segment coalescing). The
173
+ // cursor advances in O(1) per frame, making the whole pass O(total bytes).
174
+ let offset = 0;
175
+ while (buffer.length - offset >= 4) {
170
176
  // Read length prefix (big-endian u32) using unsigned right shift to ensure positive value
171
177
  // Using >>> 0 at the end converts the result to an unsigned 32-bit integer
172
- const length = ((this.buffer[0] << 24) |
173
- (this.buffer[1] << 16) |
174
- (this.buffer[2] << 8) |
175
- this.buffer[3]) >>>
178
+ const length = ((buffer[offset] << 24) |
179
+ (buffer[offset + 1] << 16) |
180
+ (buffer[offset + 2] << 8) |
181
+ buffer[offset + 3]) >>>
176
182
  0;
177
183
  // Validate frame size to prevent memory exhaustion DoS. A single frame's
178
184
  // declared length can never exceed maxFrameSize (64MB), so the partial
@@ -185,15 +191,30 @@ export class FrameParser {
185
191
  this.buffer = new Uint8Array(0);
186
192
  throw new FrameSizeError(length, this.maxFrameSize);
187
193
  }
188
- if (this.buffer.length < 4 + length) {
194
+ if (buffer.length - offset < 4 + length) {
189
195
  // Not enough data for this (legal, < maxFrameSize) frame yet. Keep the
190
196
  // partial bytes buffered until the rest of the frame arrives across
191
197
  // subsequent TCP segments.
192
198
  break;
193
199
  }
194
- // Extract frame
195
- frames.push(this.buffer.slice(4, 4 + length));
196
- this.buffer = this.buffer.slice(4 + length);
200
+ // Extract frame. The body is copied out (slice) so it never aliases the
201
+ // retained tail buffer.
202
+ frames.push(buffer.slice(offset + 4, offset + 4 + length));
203
+ offset += 4 + length;
204
+ }
205
+ // Retain only the unconsumed tail. Three cases mirror the previous behavior
206
+ // without the per-frame reslice:
207
+ // - fully drained: a fresh empty buffer (do not pin the read's ArrayBuffer)
208
+ // - nothing consumed: keep the concat buffer as-is (no extra copy)
209
+ // - partial leftover after ≥1 frame: copy the small tail (no aliasing)
210
+ if (offset >= buffer.length) {
211
+ this.buffer = new Uint8Array(0);
212
+ }
213
+ else if (offset === 0) {
214
+ this.buffer = buffer;
215
+ }
216
+ else {
217
+ this.buffer = buffer.slice(offset);
197
218
  }
198
219
  return frames;
199
220
  }
@@ -135,7 +135,10 @@ export function createTcpServer(queueManager, config) {
135
135
  }
136
136
  let frames;
137
137
  try {
138
- frames = frameParser.addData(new Uint8Array(data));
138
+ // `data` is a Bun Buffer (a Uint8Array). addData copies it into its own
139
+ // buffer synchronously and never retains it, so the previous defensive
140
+ // `new Uint8Array(data)` wrapper was a redundant full copy per read.
141
+ frames = frameParser.addData(data);
139
142
  }
140
143
  catch (err) {
141
144
  if (err instanceof FrameSizeError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue",
3
- "version": "2.8.23",
3
+ "version": "2.8.25",
4
4
  "description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external dependencies.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
package/dist/main 2.js DELETED
@@ -1,250 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * bunqueue - High-performance job queue server for Bun
4
- * Main entry point - routes to CLI for client commands or starts server
5
- */
6
- // Only run startup dispatch when this file is the program entry point.
7
- // Issue #85: re-exporting `defineConfig` means user config files import this
8
- // module; without this guard, the top-level dispatch would re-run the CLI/server
9
- // on every import and cause "Failed to listen at 0.0.0.0".
10
- if (import.meta.main) {
11
- const clientCommands = [
12
- 'push',
13
- 'pull',
14
- 'ack',
15
- 'fail',
16
- 'job',
17
- 'queue',
18
- 'dlq',
19
- 'cron',
20
- 'worker',
21
- 'webhook',
22
- 'rate-limit',
23
- 'concurrency',
24
- 'stats',
25
- 'metrics',
26
- 'health',
27
- 'backup',
28
- ];
29
- const firstArg = process.argv[2];
30
- const isClientCommand = firstArg && clientCommands.includes(firstArg);
31
- const isStartCommand = firstArg === 'start';
32
- const hasHelpOrVersion = process.argv.includes('--help') || process.argv.includes('--version');
33
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- process.argv[2] can be undefined at runtime
34
- const hasFlags = firstArg?.startsWith('-');
35
- if (isClientCommand || hasHelpOrVersion || isStartCommand || hasFlags) {
36
- void import('./cli/index').then(({ main }) => main());
37
- }
38
- else {
39
- void startServer();
40
- }
41
- }
42
- import { QueueManager } from './application/queueManager';
43
- import { createTcpServer } from './infrastructure/server/tcp';
44
- import { createHttpServer } from './infrastructure/server/http';
45
- import { Logger, serverLog, statsLog } from './shared/logger';
46
- import { stopRateLimiter } from './infrastructure/server/rateLimiter';
47
- import { VERSION } from './shared/version';
48
- import { S3BackupManager } from './infrastructure/backup';
49
- import { CloudAgent } from './infrastructure/cloud';
50
- import { SHARD_COUNT } from './shared/hash';
51
- import { loadConfigFile, resolveServerConfig, resolveCloudConfig, resolveBackupConfig, } from './config';
52
- export { defineConfig } from './config';
53
- /** Print startup banner */
54
- function printBanner(config, cloudUrl) {
55
- const dim = '\x1b[2m';
56
- const reset = '\x1b[0m';
57
- const bold = '\x1b[1m';
58
- const magenta = '\x1b[35m';
59
- const green = '\x1b[32m';
60
- const yellow = '\x1b[33m';
61
- // Format TCP endpoint display
62
- const tcpDisplay = config.tcpSocketPath
63
- ? `${bold}${config.tcpSocketPath}${reset} ${dim}(unix)${reset}`
64
- : `${bold}${config.hostname}:${config.tcpPort}${reset}`;
65
- // Format HTTP endpoint display
66
- const httpDisplay = config.httpSocketPath
67
- ? `${bold}${config.httpSocketPath}${reset} ${dim}(unix)${reset}`
68
- : `${bold}${config.hostname}:${config.httpPort}${reset}`;
69
- // Socket mode display
70
- const hasUnixSockets = config.tcpSocketPath !== undefined || config.httpSocketPath !== undefined;
71
- const socketDisplay = hasUnixSockets
72
- ? `${green}enabled${reset} ${dim}(${config.tcpSocketPath ? 'TCP' : ''}${config.tcpSocketPath && config.httpSocketPath ? '+' : ''}${config.httpSocketPath ? 'HTTP' : ''})${reset}`
73
- : `${dim}disabled${reset}`;
74
- console.log(`
75
- ${magenta} (\\(\\ ${reset}
76
- ${magenta} ( -.-) ${bold}bunqueue${reset} ${dim}v${VERSION}${reset}
77
- ${magenta} o_(")(") ${reset}${dim}High-performance job queue for Bun${reset}
78
-
79
- ${dim}─────────────────────────────────────────────────${reset}
80
-
81
- ${green}●${reset} TCP ${tcpDisplay}
82
- ${green}●${reset} HTTP ${httpDisplay}
83
- ${yellow}●${reset} Socket ${socketDisplay}
84
- ${yellow}●${reset} Data ${config.dataPath ?? 'in-memory'}
85
- ${yellow}●${reset} Auth ${config.authTokens.length > 0 ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
86
- ${yellow}●${reset} S3 Backup ${config.s3BackupEnabled ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
87
- ${yellow}●${reset} Cloud ${cloudUrl ? `${green}enabled${reset} ${dim}→ ${cloudUrl}${reset}` : `${dim}disabled${reset}`}
88
- ${dim}●${reset} Shards ${bold}${SHARD_COUNT}${reset} ${dim}(${navigator.hardwareConcurrency} CPU cores)${reset}
89
-
90
- ${dim}─────────────────────────────────────────────────${reset}
91
-
92
- `);
93
- }
94
- /** Start the server (direct mode) */
95
- async function startServer() {
96
- // Load config file (bunqueue.config.ts) if present, then merge with env vars
97
- const fileConfig = await loadConfigFile();
98
- const config = resolveServerConfig(fileConfig);
99
- // Apply logging config before anything else
100
- const logFormat = fileConfig?.logging?.format ?? Bun.env.LOG_FORMAT;
101
- const logLevel = fileConfig?.logging?.level ?? Bun.env.LOG_LEVEL?.toLowerCase();
102
- if (logFormat === 'json')
103
- Logger.enableJsonMode();
104
- if (logLevel) {
105
- const validLevels = ['debug', 'info', 'warn', 'error'];
106
- if (validLevels.includes(logLevel))
107
- Logger.setLevel(logLevel);
108
- }
109
- // Resolve cloud config
110
- const cloudConfig = resolveCloudConfig(fileConfig, config.dataPath);
111
- printBanner(config, cloudConfig?.url);
112
- // Create queue manager
113
- const queueManager = new QueueManager({
114
- dataPath: config.dataPath,
115
- });
116
- // Start TCP server
117
- const tcpServer = createTcpServer(queueManager, {
118
- port: config.tcpPort,
119
- hostname: config.hostname,
120
- authTokens: config.authTokens,
121
- });
122
- // Start HTTP server
123
- const httpServer = createHttpServer(queueManager, {
124
- port: config.httpPort,
125
- hostname: config.hostname,
126
- authTokens: config.authTokens,
127
- corsOrigins: config.corsOrigins,
128
- requireAuthForMetrics: config.requireAuthForMetrics,
129
- });
130
- // Initialize S3 backup manager
131
- let backupManager = null;
132
- if (config.dataPath) {
133
- const backupConfig = resolveBackupConfig(fileConfig, config.dataPath);
134
- backupManager = new S3BackupManager(backupConfig);
135
- backupManager.setDashboardEmit(queueManager.emitDashboardEvent.bind(queueManager));
136
- backupManager.start();
137
- }
138
- // Initialize bunqueue Cloud agent (remote dashboard telemetry)
139
- const cloudAgent = cloudConfig ? CloudAgent.createFromConfig(queueManager, cloudConfig) : null;
140
- if (cloudAgent) {
141
- cloudAgent.setServerHandles({
142
- getConnectionCount: () => tcpServer.getConnectionCount(),
143
- getWsClientCount: () => httpServer.getWsClientCount(),
144
- getSseClientCount: () => httpServer.getSseClientCount(),
145
- getBackupStatus: () => backupManager?.getStatus() ?? null,
146
- });
147
- }
148
- queueManager.emitDashboardEvent('server:started', {
149
- tcpPort: config.tcpPort,
150
- httpPort: config.httpPort,
151
- shards: SHARD_COUNT,
152
- });
153
- // Graceful shutdown
154
- let shuttingDown = false;
155
- const shutdown = async (signal) => {
156
- if (shuttingDown)
157
- return;
158
- shuttingDown = true;
159
- serverLog.info(`Received ${signal}, shutting down...`);
160
- // Stop stats interval immediately
161
- clearInterval(statsInterval);
162
- tcpServer.stop();
163
- httpServer.stop();
164
- const shutdownTimeout = config.shutdownTimeoutMs;
165
- const start = Date.now();
166
- while (Date.now() - start < shutdownTimeout) {
167
- const stats = queueManager.getStats();
168
- if (stats.active === 0)
169
- break;
170
- serverLog.info(`Waiting for ${stats.active} active jobs...`);
171
- await Bun.sleep(1000);
172
- }
173
- // Stop backup manager
174
- if (backupManager) {
175
- backupManager.stop();
176
- }
177
- // Stop Cloud agent (sends final shutdown snapshot)
178
- if (cloudAgent) {
179
- await cloudAgent.stop();
180
- }
181
- queueManager.emitDashboardEvent('server:shutdown', { signal });
182
- queueManager.shutdown();
183
- stopRateLimiter();
184
- serverLog.info('Shutdown complete');
185
- process.exit(0);
186
- };
187
- process.on('SIGINT', () => void shutdown('SIGINT'));
188
- process.on('SIGTERM', () => void shutdown('SIGTERM'));
189
- process.on('uncaughtException', (err) => {
190
- serverLog.error('Uncaught exception - initiating shutdown', {
191
- error: err.message,
192
- stack: err.stack,
193
- });
194
- void shutdown('uncaughtException');
195
- });
196
- process.on('unhandledRejection', (reason) => {
197
- serverLog.error('Unhandled promise rejection - initiating shutdown', {
198
- reason: reason instanceof Error ? reason.message : String(reason),
199
- stack: reason instanceof Error ? reason.stack : undefined,
200
- });
201
- void shutdown('unhandledRejection');
202
- });
203
- // Print stats periodically
204
- const statsInterval = setInterval(() => {
205
- const stats = queueManager.getStats();
206
- const memStats = queueManager.getMemoryStats();
207
- const workerStats = queueManager.workerManager.getStats();
208
- const mem = process.memoryUsage();
209
- const now = new Date();
210
- const timestamp = now.toLocaleTimeString('en-GB', {
211
- hour: '2-digit',
212
- minute: '2-digit',
213
- second: '2-digit',
214
- });
215
- statsLog.info('Queue statistics', {
216
- time: timestamp,
217
- waiting: stats.waiting,
218
- active: stats.active,
219
- delayed: stats.delayed,
220
- completed: stats.completed,
221
- dlq: stats.dlq,
222
- tcp: tcpServer.getConnectionCount(),
223
- ws: httpServer.getWsClientCount(),
224
- sse: httpServer.getSseClientCount(),
225
- workers: `${workerStats.active}/${workerStats.total}`,
226
- mem: `${Math.round(mem.heapUsed / 1024 / 1024)}MB/${Math.round(mem.heapTotal / 1024 / 1024)}MB`,
227
- rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
228
- // Internal collection sizes (for memory debugging)
229
- idx: memStats.jobIndex,
230
- locks: memStats.jobLocks,
231
- clients: memStats.clientJobsTotal,
232
- });
233
- }, config.statsIntervalMs);
234
- }
235
- // Logger env-var bootstrap only applies when this file is the entry point.
236
- // Imported consumers (e.g. user config files using `defineConfig`) must not
237
- // have their process Logger state mutated as a side effect — see Issue #85.
238
- if (import.meta.main) {
239
- if (Bun.env.LOG_FORMAT === 'json') {
240
- Logger.enableJsonMode();
241
- }
242
- if (Bun.env.LOG_LEVEL) {
243
- const validLevels = ['debug', 'info', 'warn', 'error'];
244
- const level = Bun.env.LOG_LEVEL.toLowerCase();
245
- if (validLevels.includes(level)) {
246
- Logger.setLevel(level);
247
- }
248
- }
249
- }
250
- //# sourceMappingURL=main.js.map