@open-mercato/queue 0.6.8-develop.6948.1.8369fc4c97 → 0.6.8-develop.6958.1.6696e8db69

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.
@@ -17,6 +17,7 @@ function payloadMatchesScope(payload, scope) {
17
17
  return true;
18
18
  }
19
19
  const DEFAULT_POLL_INTERVAL = 1e3;
20
+ const DEFAULT_FALLBACK_POLL_INTERVAL = 5e3;
20
21
  const DEFAULT_LOCAL_QUEUE_BASE_DIR = ".mercato/queue";
21
22
  const DEFAULT_MAX_ATTEMPTS = 3;
22
23
  const RETRY_BACKOFF_BASE_MS = 1e3;
@@ -39,8 +40,15 @@ function createLocalQueue(name, options) {
39
40
  const logger = packageLogger.child({ queue: name });
40
41
  const concurrency = options?.concurrency ?? 1;
41
42
  const pollInterval = options?.pollInterval ?? DEFAULT_POLL_INTERVAL;
43
+ const fallbackPollInterval = Math.max(pollInterval, DEFAULT_FALLBACK_POLL_INTERVAL);
42
44
  let pollingTimer = null;
45
+ let queuedPollTimer = null;
46
+ let queueWatcher = null;
47
+ let queueWatcherIdentity = null;
48
+ let watcherRefreshChain = Promise.resolve();
49
+ let hasQueuedJobs = false;
43
50
  let isProcessing = false;
51
+ let pollRequested = false;
44
52
  let activeHandler = null;
45
53
  const inFlightJobIds = /* @__PURE__ */ new Set();
46
54
  let fileOpChain = Promise.resolve();
@@ -265,6 +273,7 @@ function createLocalQueue(name, options) {
265
273
  const jobsRead = await readQueue();
266
274
  return { state: stateRead, jobs: jobsRead };
267
275
  });
276
+ hasQueuedJobs = jobs.length > 0;
268
277
  const pendingJobs = jobs.filter((job) => {
269
278
  if (!job.availableAt) return true;
270
279
  return new Date(job.availableAt).getTime() <= Date.now();
@@ -321,6 +330,7 @@ function createLocalQueue(name, options) {
321
330
  const currentJobs = await readQueue();
322
331
  const updatedJobs = currentJobs.filter((j) => !completedJobIds.has(j.id) && !deadJobIds.has(j.id)).map((j) => retryUpdates.get(j.id) ?? j);
323
332
  await writeQueue(updatedJobs);
333
+ hasQueuedJobs = updatedJobs.length > 0;
324
334
  const newState = {
325
335
  lastProcessedId: lastJobId,
326
336
  completedCount: (state.completedCount ?? 0) + processed,
@@ -336,28 +346,106 @@ function createLocalQueue(name, options) {
336
346
  }
337
347
  }
338
348
  }
339
- async function pollAndProcess() {
340
- if (isProcessing || !activeHandler) return;
349
+ async function pollAndProcess(rethrow = false) {
350
+ if (!activeHandler) return;
351
+ if (isProcessing) {
352
+ pollRequested = true;
353
+ return;
354
+ }
341
355
  isProcessing = true;
342
356
  try {
343
- await processBatch(activeHandler);
357
+ do {
358
+ pollRequested = false;
359
+ const handler = activeHandler;
360
+ if (!handler) break;
361
+ await processBatch(handler);
362
+ } while (pollRequested);
344
363
  } catch (error) {
364
+ if (rethrow) throw error;
345
365
  logger.error("Polling error", { err: error });
346
366
  } finally {
347
367
  isProcessing = false;
368
+ scheduleQueuedPoll();
369
+ }
370
+ }
371
+ function scheduleQueuedPoll() {
372
+ if (!activeHandler || !hasQueuedJobs) {
373
+ if (queuedPollTimer) {
374
+ clearTimeout(queuedPollTimer);
375
+ queuedPollTimer = null;
376
+ }
377
+ return;
378
+ }
379
+ if (queuedPollTimer) return;
380
+ queuedPollTimer = setTimeout(() => {
381
+ queuedPollTimer = null;
382
+ void pollAndProcess();
383
+ }, pollInterval);
384
+ }
385
+ function closeQueueWatcher() {
386
+ if (queueWatcher) {
387
+ queueWatcher.close();
388
+ queueWatcher = null;
348
389
  }
390
+ queueWatcherIdentity = null;
391
+ }
392
+ function refreshQueueWatcher() {
393
+ const refresh = watcherRefreshChain.then(async () => {
394
+ if (!activeHandler) return;
395
+ try {
396
+ await ensureDir();
397
+ const stats = await fsp.stat(queueFile);
398
+ const nextIdentity = { device: stats.dev, inode: stats.ino };
399
+ if (queueWatcher && queueWatcherIdentity?.device === nextIdentity.device && queueWatcherIdentity.inode === nextIdentity.inode) {
400
+ return;
401
+ }
402
+ closeQueueWatcher();
403
+ const watcher = fs.watch(queueFile, (eventType) => {
404
+ if (eventType === "rename") {
405
+ queueWatcherIdentity = null;
406
+ void refreshQueueWatcher();
407
+ }
408
+ void pollAndProcess();
409
+ });
410
+ watcher.on("error", (err) => {
411
+ logger.error("Queue watch error; fallback polling remains active", { err });
412
+ if (queueWatcher === watcher) {
413
+ closeQueueWatcher();
414
+ }
415
+ });
416
+ if (!activeHandler) {
417
+ watcher.close();
418
+ return;
419
+ }
420
+ queueWatcher = watcher;
421
+ queueWatcherIdentity = nextIdentity;
422
+ } catch (err) {
423
+ logger.error("Failed to watch queue file; fallback polling remains active", { err });
424
+ }
425
+ });
426
+ watcherRefreshChain = refresh.catch(() => void 0);
427
+ return refresh;
349
428
  }
350
429
  async function process(handler, options2) {
351
430
  if (options2?.limit) {
352
431
  return processBatch(handler, options2);
353
432
  }
433
+ if (activeHandler) {
434
+ await close();
435
+ }
354
436
  activeHandler = handler;
355
- await processBatch(handler);
437
+ try {
438
+ await refreshQueueWatcher();
439
+ await pollAndProcess(true);
440
+ } catch (error) {
441
+ await close();
442
+ throw error;
443
+ }
356
444
  pollingTimer = setInterval(() => {
357
- pollAndProcess().catch((err) => {
445
+ refreshQueueWatcher().then(() => pollAndProcess()).catch((err) => {
358
446
  logger.error("Poll cycle error", { err });
359
447
  });
360
- }, pollInterval);
448
+ }, fallbackPollInterval);
361
449
  logger.info("Worker started", { concurrency });
362
450
  return { processed: -1, failed: -1, lastJobId: void 0 };
363
451
  }
@@ -366,6 +454,8 @@ function createLocalQueue(name, options) {
366
454
  const jobs = await readQueue();
367
455
  const removed = jobs.length;
368
456
  await writeQueue([]);
457
+ hasQueuedJobs = false;
458
+ scheduleQueuedPoll();
369
459
  const state = await readState();
370
460
  await writeState({
371
461
  completedCount: state.completedCount,
@@ -382,15 +472,22 @@ function createLocalQueue(name, options) {
382
472
  if (removed > 0) {
383
473
  await writeQueue(retainedJobs);
384
474
  }
475
+ hasQueuedJobs = retainedJobs.length > 0;
476
+ scheduleQueuedPoll();
385
477
  return { removed };
386
478
  });
387
479
  }
388
480
  async function close() {
481
+ activeHandler = null;
482
+ if (queuedPollTimer) {
483
+ clearTimeout(queuedPollTimer);
484
+ queuedPollTimer = null;
485
+ }
486
+ closeQueueWatcher();
389
487
  if (pollingTimer) {
390
488
  clearInterval(pollingTimer);
391
489
  pollingTimer = null;
392
490
  }
393
- activeHandler = null;
394
491
  const SHUTDOWN_TIMEOUT = 5e3;
395
492
  const startTime = Date.now();
396
493
  while (isProcessing) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/strategies/local.ts"],
4
- "sourcesContent": ["import fs from 'node:fs'\nimport path from 'node:path'\nimport crypto from 'node:crypto'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { Queue, QueuedJob, JobHandler, LocalQueueOptions, ProcessOptions, ProcessResult, EnqueueOptions, QueueJobScope } from '../types'\nimport { attachTraceMetadata, runJobInTrace } from '../tracing'\n\nconst packageLogger = createLogger('queue')\n\ntype LocalState = {\n lastProcessedId?: string\n completedCount?: number\n failedCount?: number\n}\n\ntype StoredJob<T> = QueuedJob<T> & {\n availableAt?: string\n attemptCount?: number\n}\n\nfunction payloadMatchesScope(payload: unknown, scope: QueueJobScope): boolean {\n if (!payload || typeof payload !== 'object') return false\n const scopedPayload = payload as { tenantId?: unknown; organizationId?: unknown; jobType?: unknown }\n if (scopedPayload.tenantId !== scope.tenantId) return false\n if (scope.organizationId !== undefined) {\n if ((scopedPayload.organizationId ?? null) !== scope.organizationId) return false\n }\n if (scope.jobTypes?.length) {\n return typeof scopedPayload.jobType === 'string' && scope.jobTypes.includes(scopedPayload.jobType)\n }\n return true\n}\n\n/** Default polling interval in milliseconds */\nconst DEFAULT_POLL_INTERVAL = 1000\nconst DEFAULT_LOCAL_QUEUE_BASE_DIR = '.mercato/queue'\nconst DEFAULT_MAX_ATTEMPTS = 3\nconst RETRY_BACKOFF_BASE_MS = 1000\n\n/**\n * Cross-process lock tuning. A held lock only ever spans local file I/O \u2014 job\n * handlers run outside it \u2014 so realistic hold times are milliseconds and the\n * stale threshold sits orders of magnitude above them. It exists solely so a\n * process that dies mid-segment cannot wedge the queue forever. A holder that\n * was merely suspended rather than dead can still be reclaimed, which is why\n * every acquisition carries an owner token and releases only its own lock.\n */\nconst LOCK_STALE_MS = 15_000\nconst LOCK_ACQUIRE_TIMEOUT_MS = 30_000\nconst LOCK_RETRY_MIN_MS = 2\nconst LOCK_RETRY_MAX_MS = 20\nconst RENAME_MAX_RETRIES = 5\nconst RENAME_RETRY_BASE_MS = 10\n\nconst fsp = fs.promises\n\n/**\n * Creates a file-based local queue.\n *\n * Jobs are stored in JSON files within a directory structure:\n * - `.mercato/queue/<name>/queue.json` - Array of queued jobs\n * - `.mercato/queue/<name>/state.json` - Processing state (last processed ID)\n *\n * **Limitations:**\n * - Jobs are processed sequentially (concurrency option is for logging/compatibility only)\n * - Not suitable for production: there is no dead-letter store, no throughput\n * beyond one job at a time, and every operation rewrites the whole queue file\n *\n * Multiple processes MAY share a queue directory, which is the default\n * development topology: the dev worker runs in its own process alongside the\n * Next.js server. What that buys you, and what it does not:\n *\n * - **Safe** \u2014 concurrent producers. Every read-modify-write segment takes the\n * `queue.lock` directory lock and every persist swaps the file in with an\n * atomic rename, so the file cannot be torn, no enqueue is lost to a\n * concurrent one, and a reader always observes one complete document.\n * Writers contend, though, so throughput degrades as processes are added.\n * - **NOT safe** \u2014 concurrent consumers. `process()` deliberately runs job\n * handlers outside the lock, so two worker processes polling the same queue\n * would both claim the same pending jobs and execute them twice. There is no\n * per-job lease. Run exactly one worker process per queue; use the `async`\n * strategy when you need more than one.\n *\n * Failed jobs are retried up to `DEFAULT_MAX_ATTEMPTS` times with exponential backoff.\n * **This strategy keeps no failed-job store**: once attempts are exhausted the job is\n * removed from `queue.json` and only counted in `state.failedCount`, so the payload is\n * lost and the failure survives solely as an error log line. The `async` strategy keeps\n * only a bounded inspection window: `removeOnFail: 1000` retains the most recent 1000\n * failures and removes older ones as later failures arrive. Workflows that require\n * no-loss persistence must write their own durable record before enqueueing, regardless\n * of strategy.\n *\n * `DEFAULT_MAX_ATTEMPTS` is a module constant, not a per-job option \u2014 callers cannot\n * request a different attempt count. (`async` likewise hard-codes `attempts: 3`.)\n * See the retry handling in `process()` below.\n *\n * All file I/O is asynchronous (`fs.promises.*`) so queue operations do not\n * block the Node.js event loop. A per-queue promise chain serializes\n * read-modify-write sequences within one instance, and the `queue.lock`\n * directory lock extends that serialization across instances and processes.\n *\n * @template T - The payload type for jobs\n * @param name - Queue name (used for directory naming)\n * @param options - Local queue options\n */\nexport function createLocalQueue<T = unknown>(\n name: string,\n options?: LocalQueueOptions\n): Queue<T> {\n const nodeProcess = (globalThis as typeof globalThis & { process?: NodeJS.Process }).process\n const queueBaseDirFromEnv = nodeProcess?.env?.QUEUE_BASE_DIR\n const baseDir = options?.baseDir\n ?? path.resolve(queueBaseDirFromEnv || DEFAULT_LOCAL_QUEUE_BASE_DIR)\n const queueDir = path.join(baseDir, name)\n const queueFile = path.join(queueDir, 'queue.json')\n const stateFile = path.join(queueDir, 'state.json')\n const lockDir = path.join(queueDir, 'queue.lock')\n const lockOwnerFile = path.join(lockDir, 'owner')\n const logger = packageLogger.child({ queue: name })\n // Note: concurrency is stored for logging/compatibility but jobs are processed sequentially\n const concurrency = options?.concurrency ?? 1\n const pollInterval = options?.pollInterval ?? DEFAULT_POLL_INTERVAL\n\n // Worker state for continuous polling\n let pollingTimer: ReturnType<typeof setInterval> | null = null\n let isProcessing = false\n let activeHandler: JobHandler<T> | null = null\n const inFlightJobIds = new Set<string>()\n\n // Per-queue mutex. Serializes read-modify-write segments so async fs calls\n // cannot interleave and clobber each other's writes. It only covers this\n // instance, so it also guarantees at most one outstanding `queue.lock`\n // acquisition per instance \u2014 the directory lock below is not reentrant.\n let fileOpChain: Promise<unknown> = Promise.resolve()\n function withFileLock<R>(fn: () => Promise<R>): Promise<R> {\n const run = fileOpChain.then(\n () => runExclusively(fn),\n () => runExclusively(fn),\n )\n fileOpChain = run.then(\n () => undefined,\n () => undefined,\n )\n return run\n }\n\n /**\n * Runs `fn` while holding the cross-process `queue.lock`, so read-modify-write\n * segments issued by other queue instances \u2014 in this process or another one \u2014\n * cannot interleave with it.\n */\n async function runExclusively<R>(fn: () => Promise<R>): Promise<R> {\n await ensureDir()\n const release = await acquireDirectoryLock()\n try {\n return await fn()\n } finally {\n await release()\n }\n }\n\n // -------------------------------------------------------------------------\n // File Operations\n // -------------------------------------------------------------------------\n\n function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => { setTimeout(resolve, ms) })\n }\n\n async function lockHeldForMs(): Promise<number | null> {\n try {\n const stats = await fsp.stat(lockDir)\n return Date.now() - stats.mtimeMs\n } catch {\n return null\n }\n }\n\n /**\n * Reclaims a lock whose holder died. The rename is the serialization point:\n * only one racer can move `queue.lock` aside, so two processes cannot both\n * decide a stale lock is theirs to clear and then both create a fresh one.\n */\n async function reclaimStaleLock(heldForMs: number): Promise<void> {\n const reclaimedPath = `${lockDir}.stale.${crypto.randomUUID()}`\n try {\n await fsp.rename(lockDir, reclaimedPath)\n } catch {\n return\n }\n logger.warn('Reclaimed a stale queue lock', { lockDir, heldForMs })\n await fsp.rm(reclaimedPath, { recursive: true, force: true }).catch(() => {})\n }\n\n async function readLockOwner(): Promise<string | null> {\n try {\n return await fsp.readFile(lockOwnerFile, 'utf8')\n } catch {\n return null\n }\n }\n\n /**\n * Releases the lock only when this acquisition still owns it. A holder that\n * was suspended past `LOCK_STALE_MS` has had its lock reclaimed *and\n * replaced* by whoever reclaimed it, so an unconditional removal here would\n * delete the successor's lock and let a third caller into the critical\n * section alongside it. A missing or mismatched token means someone else owns\n * the path now, and the correct action is to leave it alone.\n */\n async function releaseDirectoryLock(token: string): Promise<void> {\n if (await readLockOwner() !== token) return\n await fsp.rm(lockDir, { recursive: true, force: true }).catch(() => {})\n }\n\n /**\n * Acquires the cross-process advisory lock for this queue directory.\n * `mkdir` without `recursive` is an atomic exclusive create on every platform\n * Node.js supports, which makes it the portable primitive here \u2014 no runtime\n * dependency, and no reliance on advisory `flock` semantics. The owner token\n * written into the directory is what lets the release distinguish this\n * acquisition from a successor's.\n */\n async function acquireDirectoryLock(): Promise<() => Promise<void>> {\n const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS\n\n for (;;) {\n let acquired = false\n try {\n await fsp.mkdir(lockDir)\n acquired = true\n } catch (e: unknown) {\n const error = e as NodeJS.ErrnoException\n if (error.code !== 'EEXIST') throw error\n }\n\n if (acquired) {\n const token = crypto.randomUUID()\n try {\n await fsp.writeFile(lockOwnerFile, token, 'utf8')\n } catch (error: unknown) {\n await fsp.rm(lockDir, { recursive: true, force: true }).catch(() => {})\n throw error\n }\n return () => releaseDirectoryLock(token)\n }\n\n const heldForMs = await lockHeldForMs()\n if (heldForMs !== null && heldForMs > LOCK_STALE_MS) {\n await reclaimStaleLock(heldForMs)\n continue\n }\n\n if (Date.now() >= deadline) {\n throw new Error(\n `[internal] Timed out after ${LOCK_ACQUIRE_TIMEOUT_MS}ms waiting for the queue lock at ${lockDir}`,\n )\n }\n\n const jitter = LOCK_RETRY_MIN_MS + Math.random() * (LOCK_RETRY_MAX_MS - LOCK_RETRY_MIN_MS)\n await sleep(jitter)\n }\n }\n\n /**\n * Persists `content` by writing a unique sibling temp file and renaming it\n * onto `targetFile`. `rename` within a directory is atomic, so a concurrent\n * reader sees either the previous document or the new one in full \u2014 never the\n * torn result of a truncate-then-write.\n */\n async function writeFileAtomic(targetFile: string, content: string): Promise<void> {\n const tempFile = `${targetFile}.${crypto.randomUUID()}.tmp`\n try {\n await fsp.writeFile(tempFile, content, 'utf8')\n await renameWithContentionRetry(tempFile, targetFile)\n } catch (error: unknown) {\n await fsp.rm(tempFile, { force: true }).catch(() => {})\n throw error\n }\n }\n\n /**\n * Windows rejects a rename onto a file another process currently has open,\n * so retry briefly on the contention codes it raises. POSIX renames replace\n * the target unconditionally and take the first attempt.\n */\n async function renameWithContentionRetry(fromFile: string, toFile: string): Promise<void> {\n const contentionCodes = new Set(['EPERM', 'EBUSY', 'EACCES'])\n for (let attempt = 0; ; attempt++) {\n try {\n await fsp.rename(fromFile, toFile)\n return\n } catch (e: unknown) {\n const error = e as NodeJS.ErrnoException\n if (attempt >= RENAME_MAX_RETRIES || !error.code || !contentionCodes.has(error.code)) throw error\n await sleep(RENAME_RETRY_BASE_MS * (attempt + 1))\n }\n }\n }\n\n async function ensureDir(): Promise<void> {\n try {\n await fsp.mkdir(queueDir, { recursive: true })\n } catch (e: unknown) {\n const error = e as NodeJS.ErrnoException\n if (error.code !== 'EEXIST') throw error\n }\n\n // Initialize queue file with exclusive create flag\n try {\n await fsp.writeFile(queueFile, '[]', { encoding: 'utf8', flag: 'wx' })\n } catch (e: unknown) {\n const error = e as NodeJS.ErrnoException\n if (error.code !== 'EEXIST') throw error\n }\n\n // Initialize state file with exclusive create flag\n try {\n await fsp.writeFile(stateFile, '{}', { encoding: 'utf8', flag: 'wx' })\n } catch (e: unknown) {\n const error = e as NodeJS.ErrnoException\n if (error.code !== 'EEXIST') throw error\n }\n }\n\n /**\n * Moves an unparsable queue file aside so its jobs stay recoverable. The\n * caller is expected to surface the failure rather than continue on an empty\n * queue: silently recreating `queue.json` here is what turned an unreadable\n * file into permanent, unreported job loss.\n */\n async function quarantineCorruptedQueueFile(): Promise<string | null> {\n const backupFile = path.join(queueDir, `queue.corrupted.${Date.now()}.${crypto.randomUUID()}.json`)\n try {\n await fsp.rename(queueFile, backupFile)\n return backupFile\n } catch (e: unknown) {\n logger.error('Failed to quarantine the corrupted queue file', { err: e as Error })\n return null\n }\n }\n\n async function readQueue(): Promise<StoredJob<T>[]> {\n await ensureDir()\n let content: string\n\n try {\n content = await fsp.readFile(queueFile, 'utf8')\n } catch (error: unknown) {\n const readError = error as NodeJS.ErrnoException\n if (readError.code === 'ENOENT') {\n return []\n }\n logger.error('Failed to read queue file', { err: readError })\n throw new Error(`Queue file unreadable: ${readError.message}`)\n }\n\n try {\n const parsed = JSON.parse(content) as unknown\n\n if (!Array.isArray(parsed)) {\n throw new Error('Queue file must contain a JSON array')\n }\n\n return parsed as StoredJob<T>[]\n } catch (error: unknown) {\n const parseError = error as Error\n logger.error('Failed to parse queue file', { err: parseError })\n const backupFile = await quarantineCorruptedQueueFile()\n if (backupFile) {\n logger.error('Quarantined corrupted queue file; its jobs are recoverable from the backup', { backupFile })\n }\n const recoveryHint = backupFile\n ? `has been quarantined as ${backupFile}`\n : 'could not be quarantined and was left in place'\n throw new Error(\n `[internal] Queue file ${queueFile} was unparsable and ${recoveryHint}: ${parseError.message}`,\n )\n }\n }\n\n async function writeQueue(jobs: StoredJob<T>[]): Promise<void> {\n await ensureDir()\n await writeFileAtomic(queueFile, JSON.stringify(jobs, null, 2))\n }\n\n async function readState(): Promise<LocalState> {\n await ensureDir()\n try {\n const content = await fsp.readFile(stateFile, 'utf8')\n return JSON.parse(content) as LocalState\n } catch {\n return {}\n }\n }\n\n async function writeState(state: LocalState): Promise<void> {\n await ensureDir()\n await writeFileAtomic(stateFile, JSON.stringify(state, null, 2))\n }\n\n function generateId(): string {\n return crypto.randomUUID()\n }\n\n // -------------------------------------------------------------------------\n // Queue Implementation\n // -------------------------------------------------------------------------\n\n async function enqueue(data: T, options?: EnqueueOptions): Promise<string> {\n const availableAt = options?.delayMs && options.delayMs > 0\n ? new Date(Date.now() + options.delayMs).toISOString()\n : undefined\n const metadata = attachTraceMetadata(undefined)\n const job: StoredJob<T> = {\n id: generateId(),\n payload: data,\n createdAt: new Date().toISOString(),\n ...(availableAt ? { availableAt } : {}),\n ...(metadata ? { metadata } : {}),\n }\n await withFileLock(async () => {\n const jobs = await readQueue()\n jobs.push(job)\n await writeQueue(jobs)\n })\n return job.id\n }\n\n /**\n * Process pending jobs in a single batch (internal helper).\n */\n async function processBatch(\n handler: JobHandler<T>,\n options?: ProcessOptions\n ): Promise<ProcessResult> {\n const { state, jobs } = await withFileLock(async () => {\n const stateRead = await readState()\n const jobsRead = await readQueue()\n return { state: stateRead, jobs: jobsRead }\n })\n\n const pendingJobs = jobs.filter((job) => {\n if (!job.availableAt) return true\n return new Date(job.availableAt).getTime() <= Date.now()\n })\n const jobsToProcess = options?.limit\n ? pendingJobs.slice(0, options.limit)\n : pendingJobs\n\n for (const job of jobsToProcess) {\n inFlightJobIds.add(job.id)\n }\n\n let processed = 0\n let failed = 0\n let lastJobId: string | undefined\n const completedJobIds = new Set<string>()\n const deadJobIds = new Set<string>()\n const retryUpdates = new Map<string, StoredJob<T>>()\n\n try {\n for (const job of jobsToProcess) {\n const attemptNumber = (job.attemptCount ?? 0) + 1\n try {\n await runJobInTrace(name, job.metadata, () =>\n Promise.resolve(\n handler(job, {\n jobId: job.id,\n attemptNumber,\n queueName: name,\n })\n )\n )\n processed++\n lastJobId = job.id\n completedJobIds.add(job.id)\n logger.info('Job completed', { jobId: job.id })\n } catch (error) {\n logger.error('Job failed', { jobId: job.id, attemptNumber, maxAttempts: DEFAULT_MAX_ATTEMPTS, err: error })\n failed++\n lastJobId = job.id\n if (attemptNumber >= DEFAULT_MAX_ATTEMPTS) {\n logger.error('Job exhausted all attempts; dropping it (no dead-letter store)', { jobId: job.id, maxAttempts: DEFAULT_MAX_ATTEMPTS })\n deadJobIds.add(job.id)\n } else {\n const backoffMs = RETRY_BACKOFF_BASE_MS * Math.pow(2, attemptNumber - 1)\n retryUpdates.set(job.id, {\n ...job,\n attemptCount: attemptNumber,\n availableAt: new Date(Date.now() + backoffMs).toISOString(),\n })\n }\n }\n }\n\n const hasChanges = completedJobIds.size > 0 || deadJobIds.size > 0 || retryUpdates.size > 0\n if (hasChanges) {\n await withFileLock(async () => {\n // Re-read so jobs enqueued during handler execution are preserved.\n const currentJobs = await readQueue()\n const updatedJobs = currentJobs\n .filter((j) => !completedJobIds.has(j.id) && !deadJobIds.has(j.id))\n .map((j) => retryUpdates.get(j.id) ?? j)\n await writeQueue(updatedJobs)\n\n const newState: LocalState = {\n lastProcessedId: lastJobId,\n completedCount: (state.completedCount ?? 0) + processed,\n failedCount: (state.failedCount ?? 0) + deadJobIds.size,\n }\n await writeState(newState)\n })\n }\n\n return { processed, failed, lastJobId }\n } finally {\n for (const job of jobsToProcess) {\n inFlightJobIds.delete(job.id)\n }\n }\n }\n\n /**\n * Poll for and process new jobs.\n */\n async function pollAndProcess(): Promise<void> {\n // Skip if already processing to avoid concurrent file access\n if (isProcessing || !activeHandler) return\n\n isProcessing = true\n try {\n await processBatch(activeHandler)\n } catch (error) {\n logger.error('Polling error', { err: error })\n } finally {\n isProcessing = false\n }\n }\n\n async function process(\n handler: JobHandler<T>,\n options?: ProcessOptions\n ): Promise<ProcessResult> {\n // If limit is specified, do a single batch (backward compatibility)\n if (options?.limit) {\n return processBatch(handler, options)\n }\n\n // Start continuous polling mode (like BullMQ Worker)\n activeHandler = handler\n\n // Process any pending jobs immediately\n await processBatch(handler)\n\n // Start polling interval for new jobs\n pollingTimer = setInterval(() => {\n pollAndProcess().catch((err) => {\n logger.error('Poll cycle error', { err })\n })\n }, pollInterval)\n\n logger.info('Worker started', { concurrency })\n\n // Return sentinel value indicating continuous worker mode (like async strategy)\n return { processed: -1, failed: -1, lastJobId: undefined }\n }\n\n async function clear(): Promise<{ removed: number }> {\n return withFileLock(async () => {\n const jobs = await readQueue()\n const removed = jobs.length\n await writeQueue([])\n // Reset state but preserve counts for historical tracking\n const state = await readState()\n await writeState({\n completedCount: state.completedCount,\n failedCount: state.failedCount,\n })\n return { removed }\n })\n }\n\n async function removeQueuedJobsByScope(scope: QueueJobScope): Promise<{ removed: number }> {\n return withFileLock(async () => {\n const jobs = await readQueue()\n const retainedJobs = jobs.filter((job) => inFlightJobIds.has(job.id) || !payloadMatchesScope(job.payload, scope))\n const removed = jobs.length - retainedJobs.length\n if (removed > 0) {\n await writeQueue(retainedJobs)\n }\n return { removed }\n })\n }\n\n async function close(): Promise<void> {\n // Stop polling timer\n if (pollingTimer) {\n clearInterval(pollingTimer)\n pollingTimer = null\n }\n activeHandler = null\n\n // Wait for any in-progress processing to complete (with timeout)\n const SHUTDOWN_TIMEOUT = 5000\n const startTime = Date.now()\n\n while (isProcessing) {\n if (Date.now() - startTime > SHUTDOWN_TIMEOUT) {\n logger.warn('Force closing after shutdown timeout', { timeoutMs: SHUTDOWN_TIMEOUT })\n break\n }\n await new Promise((resolve) => setTimeout(resolve, 50))\n }\n }\n\n async function getJobCounts(): Promise<{\n waiting: number\n active: number\n completed: number\n failed: number\n }> {\n return withFileLock(async () => {\n const state = await readState()\n const jobs = await readQueue()\n\n return {\n waiting: jobs.length, // All jobs in queue are waiting (processed ones are removed)\n active: 0, // Local strategy doesn't track active jobs\n completed: state.completedCount ?? 0,\n failed: state.failedCount ?? 0,\n }\n })\n }\n\n return {\n name,\n strategy: 'local',\n enqueue,\n process,\n clear,\n removeQueuedJobsByScope,\n close,\n getJobCounts,\n }\n}\n"],
5
- "mappings": "AAAA,OAAO,QAAQ;AACf,OAAO,UAAU;AACjB,OAAO,YAAY;AACnB,SAAS,oBAAoB;AAE7B,SAAS,qBAAqB,qBAAqB;AAEnD,MAAM,gBAAgB,aAAa,OAAO;AAa1C,SAAS,oBAAoB,SAAkB,OAA+B;AAC5E,MAAI,CAAC,WAAW,OAAO,YAAY,SAAU,QAAO;AACpD,QAAM,gBAAgB;AACtB,MAAI,cAAc,aAAa,MAAM,SAAU,QAAO;AACtD,MAAI,MAAM,mBAAmB,QAAW;AACtC,SAAK,cAAc,kBAAkB,UAAU,MAAM,eAAgB,QAAO;AAAA,EAC9E;AACA,MAAI,MAAM,UAAU,QAAQ;AAC1B,WAAO,OAAO,cAAc,YAAY,YAAY,MAAM,SAAS,SAAS,cAAc,OAAO;AAAA,EACnG;AACA,SAAO;AACT;AAGA,MAAM,wBAAwB;AAC9B,MAAM,+BAA+B;AACrC,MAAM,uBAAuB;AAC7B,MAAM,wBAAwB;AAU9B,MAAM,gBAAgB;AACtB,MAAM,0BAA0B;AAChC,MAAM,oBAAoB;AAC1B,MAAM,oBAAoB;AAC1B,MAAM,qBAAqB;AAC3B,MAAM,uBAAuB;AAE7B,MAAM,MAAM,GAAG;AAmDR,SAAS,iBACd,MACA,SACU;AACV,QAAM,cAAe,WAAgE;AACrF,QAAM,sBAAsB,aAAa,KAAK;AAC9C,QAAM,UAAU,SAAS,WACpB,KAAK,QAAQ,uBAAuB,4BAA4B;AACrE,QAAM,WAAW,KAAK,KAAK,SAAS,IAAI;AACxC,QAAM,YAAY,KAAK,KAAK,UAAU,YAAY;AAClD,QAAM,YAAY,KAAK,KAAK,UAAU,YAAY;AAClD,QAAM,UAAU,KAAK,KAAK,UAAU,YAAY;AAChD,QAAM,gBAAgB,KAAK,KAAK,SAAS,OAAO;AAChD,QAAM,SAAS,cAAc,MAAM,EAAE,OAAO,KAAK,CAAC;AAElD,QAAM,cAAc,SAAS,eAAe;AAC5C,QAAM,eAAe,SAAS,gBAAgB;AAG9C,MAAI,eAAsD;AAC1D,MAAI,eAAe;AACnB,MAAI,gBAAsC;AAC1C,QAAM,iBAAiB,oBAAI,IAAY;AAMvC,MAAI,cAAgC,QAAQ,QAAQ;AACpD,WAAS,aAAgB,IAAkC;AACzD,UAAM,MAAM,YAAY;AAAA,MACtB,MAAM,eAAe,EAAE;AAAA,MACvB,MAAM,eAAe,EAAE;AAAA,IACzB;AACA,kBAAc,IAAI;AAAA,MAChB,MAAM;AAAA,MACN,MAAM;AAAA,IACR;AACA,WAAO;AAAA,EACT;AAOA,iBAAe,eAAkB,IAAkC;AACjE,UAAM,UAAU;AAChB,UAAM,UAAU,MAAM,qBAAqB;AAC3C,QAAI;AACF,aAAO,MAAM,GAAG;AAAA,IAClB,UAAE;AACA,YAAM,QAAQ;AAAA,IAChB;AAAA,EACF;AAMA,WAAS,MAAM,IAA2B;AACxC,WAAO,IAAI,QAAQ,CAAC,YAAY;AAAE,iBAAW,SAAS,EAAE;AAAA,IAAE,CAAC;AAAA,EAC7D;AAEA,iBAAe,gBAAwC;AACrD,QAAI;AACF,YAAM,QAAQ,MAAM,IAAI,KAAK,OAAO;AACpC,aAAO,KAAK,IAAI,IAAI,MAAM;AAAA,IAC5B,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAOA,iBAAe,iBAAiB,WAAkC;AAChE,UAAM,gBAAgB,GAAG,OAAO,UAAU,OAAO,WAAW,CAAC;AAC7D,QAAI;AACF,YAAM,IAAI,OAAO,SAAS,aAAa;AAAA,IACzC,QAAQ;AACN;AAAA,IACF;AACA,WAAO,KAAK,gCAAgC,EAAE,SAAS,UAAU,CAAC;AAClE,UAAM,IAAI,GAAG,eAAe,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EAC9E;AAEA,iBAAe,gBAAwC;AACrD,QAAI;AACF,aAAO,MAAM,IAAI,SAAS,eAAe,MAAM;AAAA,IACjD,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAUA,iBAAe,qBAAqB,OAA8B;AAChE,QAAI,MAAM,cAAc,MAAM,MAAO;AACrC,UAAM,IAAI,GAAG,SAAS,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EACxE;AAUA,iBAAe,uBAAqD;AAClE,UAAM,WAAW,KAAK,IAAI,IAAI;AAE9B,eAAS;AACP,UAAI,WAAW;AACf,UAAI;AACF,cAAM,IAAI,MAAM,OAAO;AACvB,mBAAW;AAAA,MACb,SAAS,GAAY;AACnB,cAAM,QAAQ;AACd,YAAI,MAAM,SAAS,SAAU,OAAM;AAAA,MACrC;AAEA,UAAI,UAAU;AACZ,cAAM,QAAQ,OAAO,WAAW;AAChC,YAAI;AACF,gBAAM,IAAI,UAAU,eAAe,OAAO,MAAM;AAAA,QAClD,SAAS,OAAgB;AACvB,gBAAM,IAAI,GAAG,SAAS,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC,EAAE,MAAM,MAAM;AAAA,UAAC,CAAC;AACtE,gBAAM;AAAA,QACR;AACA,eAAO,MAAM,qBAAqB,KAAK;AAAA,MACzC;AAEA,YAAM,YAAY,MAAM,cAAc;AACtC,UAAI,cAAc,QAAQ,YAAY,eAAe;AACnD,cAAM,iBAAiB,SAAS;AAChC;AAAA,MACF;AAEA,UAAI,KAAK,IAAI,KAAK,UAAU;AAC1B,cAAM,IAAI;AAAA,UACR,8BAA8B,uBAAuB,oCAAoC,OAAO;AAAA,QAClG;AAAA,MACF;AAEA,YAAM,SAAS,oBAAoB,KAAK,OAAO,KAAK,oBAAoB;AACxE,YAAM,MAAM,MAAM;AAAA,IACpB;AAAA,EACF;AAQA,iBAAe,gBAAgB,YAAoB,SAAgC;AACjF,UAAM,WAAW,GAAG,UAAU,IAAI,OAAO,WAAW,CAAC;AACrD,QAAI;AACF,YAAM,IAAI,UAAU,UAAU,SAAS,MAAM;AAC7C,YAAM,0BAA0B,UAAU,UAAU;AAAA,IACtD,SAAS,OAAgB;AACvB,YAAM,IAAI,GAAG,UAAU,EAAE,OAAO,KAAK,CAAC,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACtD,YAAM;AAAA,IACR;AAAA,EACF;AAOA,iBAAe,0BAA0B,UAAkB,QAA+B;AACxF,UAAM,kBAAkB,oBAAI,IAAI,CAAC,SAAS,SAAS,QAAQ,CAAC;AAC5D,aAAS,UAAU,KAAK,WAAW;AACjC,UAAI;AACF,cAAM,IAAI,OAAO,UAAU,MAAM;AACjC;AAAA,MACF,SAAS,GAAY;AACnB,cAAM,QAAQ;AACd,YAAI,WAAW,sBAAsB,CAAC,MAAM,QAAQ,CAAC,gBAAgB,IAAI,MAAM,IAAI,EAAG,OAAM;AAC5F,cAAM,MAAM,wBAAwB,UAAU,EAAE;AAAA,MAClD;AAAA,IACF;AAAA,EACF;AAEA,iBAAe,YAA2B;AACxC,QAAI;AACF,YAAM,IAAI,MAAM,UAAU,EAAE,WAAW,KAAK,CAAC;AAAA,IAC/C,SAAS,GAAY;AACnB,YAAM,QAAQ;AACd,UAAI,MAAM,SAAS,SAAU,OAAM;AAAA,IACrC;AAGA,QAAI;AACF,YAAM,IAAI,UAAU,WAAW,MAAM,EAAE,UAAU,QAAQ,MAAM,KAAK,CAAC;AAAA,IACvE,SAAS,GAAY;AACnB,YAAM,QAAQ;AACd,UAAI,MAAM,SAAS,SAAU,OAAM;AAAA,IACrC;AAGA,QAAI;AACF,YAAM,IAAI,UAAU,WAAW,MAAM,EAAE,UAAU,QAAQ,MAAM,KAAK,CAAC;AAAA,IACvE,SAAS,GAAY;AACnB,YAAM,QAAQ;AACd,UAAI,MAAM,SAAS,SAAU,OAAM;AAAA,IACrC;AAAA,EACF;AAQA,iBAAe,+BAAuD;AACpE,UAAM,aAAa,KAAK,KAAK,UAAU,mBAAmB,KAAK,IAAI,CAAC,IAAI,OAAO,WAAW,CAAC,OAAO;AAClG,QAAI;AACF,YAAM,IAAI,OAAO,WAAW,UAAU;AACtC,aAAO;AAAA,IACT,SAAS,GAAY;AACnB,aAAO,MAAM,iDAAiD,EAAE,KAAK,EAAW,CAAC;AACjF,aAAO;AAAA,IACT;AAAA,EACF;AAEA,iBAAe,YAAqC;AAClD,UAAM,UAAU;AAChB,QAAI;AAEJ,QAAI;AACF,gBAAU,MAAM,IAAI,SAAS,WAAW,MAAM;AAAA,IAChD,SAAS,OAAgB;AACvB,YAAM,YAAY;AAClB,UAAI,UAAU,SAAS,UAAU;AAC/B,eAAO,CAAC;AAAA,MACV;AACA,aAAO,MAAM,6BAA6B,EAAE,KAAK,UAAU,CAAC;AAC5D,YAAM,IAAI,MAAM,0BAA0B,UAAU,OAAO,EAAE;AAAA,IAC/D;AAEA,QAAI;AACF,YAAM,SAAS,KAAK,MAAM,OAAO;AAEjC,UAAI,CAAC,MAAM,QAAQ,MAAM,GAAG;AAC1B,cAAM,IAAI,MAAM,sCAAsC;AAAA,MACxD;AAEA,aAAO;AAAA,IACT,SAAS,OAAgB;AACvB,YAAM,aAAa;AACnB,aAAO,MAAM,8BAA8B,EAAE,KAAK,WAAW,CAAC;AAC9D,YAAM,aAAa,MAAM,6BAA6B;AACtD,UAAI,YAAY;AACd,eAAO,MAAM,8EAA8E,EAAE,WAAW,CAAC;AAAA,MAC3G;AACA,YAAM,eAAe,aACjB,2BAA2B,UAAU,KACrC;AACJ,YAAM,IAAI;AAAA,QACR,yBAAyB,SAAS,uBAAuB,YAAY,KAAK,WAAW,OAAO;AAAA,MAC9F;AAAA,IACF;AAAA,EACF;AAEA,iBAAe,WAAW,MAAqC;AAC7D,UAAM,UAAU;AAChB,UAAM,gBAAgB,WAAW,KAAK,UAAU,MAAM,MAAM,CAAC,CAAC;AAAA,EAChE;AAEA,iBAAe,YAAiC;AAC9C,UAAM,UAAU;AAChB,QAAI;AACF,YAAM,UAAU,MAAM,IAAI,SAAS,WAAW,MAAM;AACpD,aAAO,KAAK,MAAM,OAAO;AAAA,IAC3B,QAAQ;AACN,aAAO,CAAC;AAAA,IACV;AAAA,EACF;AAEA,iBAAe,WAAW,OAAkC;AAC1D,UAAM,UAAU;AAChB,UAAM,gBAAgB,WAAW,KAAK,UAAU,OAAO,MAAM,CAAC,CAAC;AAAA,EACjE;AAEA,WAAS,aAAqB;AAC5B,WAAO,OAAO,WAAW;AAAA,EAC3B;AAMA,iBAAe,QAAQ,MAASA,UAA2C;AACzE,UAAM,cAAcA,UAAS,WAAWA,SAAQ,UAAU,IACtD,IAAI,KAAK,KAAK,IAAI,IAAIA,SAAQ,OAAO,EAAE,YAAY,IACnD;AACJ,UAAM,WAAW,oBAAoB,MAAS;AAC9C,UAAM,MAAoB;AAAA,MACxB,IAAI,WAAW;AAAA,MACf,SAAS;AAAA,MACT,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,MAClC,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC;AAAA,MACrC,GAAI,WAAW,EAAE,SAAS,IAAI,CAAC;AAAA,IACjC;AACA,UAAM,aAAa,YAAY;AAC7B,YAAM,OAAO,MAAM,UAAU;AAC7B,WAAK,KAAK,GAAG;AACb,YAAM,WAAW,IAAI;AAAA,IACvB,CAAC;AACD,WAAO,IAAI;AAAA,EACb;AAKA,iBAAe,aACb,SACAA,UACwB;AACxB,UAAM,EAAE,OAAO,KAAK,IAAI,MAAM,aAAa,YAAY;AACrD,YAAM,YAAY,MAAM,UAAU;AAClC,YAAM,WAAW,MAAM,UAAU;AACjC,aAAO,EAAE,OAAO,WAAW,MAAM,SAAS;AAAA,IAC5C,CAAC;AAED,UAAM,cAAc,KAAK,OAAO,CAAC,QAAQ;AACvC,UAAI,CAAC,IAAI,YAAa,QAAO;AAC7B,aAAO,IAAI,KAAK,IAAI,WAAW,EAAE,QAAQ,KAAK,KAAK,IAAI;AAAA,IACzD,CAAC;AACD,UAAM,gBAAgBA,UAAS,QAC3B,YAAY,MAAM,GAAGA,SAAQ,KAAK,IAClC;AAEJ,eAAW,OAAO,eAAe;AAC/B,qBAAe,IAAI,IAAI,EAAE;AAAA,IAC3B;AAEA,QAAI,YAAY;AAChB,QAAI,SAAS;AACb,QAAI;AACJ,UAAM,kBAAkB,oBAAI,IAAY;AACxC,UAAM,aAAa,oBAAI,IAAY;AACnC,UAAM,eAAe,oBAAI,IAA0B;AAEnD,QAAI;AACF,iBAAW,OAAO,eAAe;AAC/B,cAAM,iBAAiB,IAAI,gBAAgB,KAAK;AAChD,YAAI;AACF,gBAAM;AAAA,YAAc;AAAA,YAAM,IAAI;AAAA,YAAU,MACtC,QAAQ;AAAA,cACN,QAAQ,KAAK;AAAA,gBACX,OAAO,IAAI;AAAA,gBACX;AAAA,gBACA,WAAW;AAAA,cACb,CAAC;AAAA,YACH;AAAA,UACF;AACA;AACA,sBAAY,IAAI;AAChB,0BAAgB,IAAI,IAAI,EAAE;AAC1B,iBAAO,KAAK,iBAAiB,EAAE,OAAO,IAAI,GAAG,CAAC;AAAA,QAChD,SAAS,OAAO;AACd,iBAAO,MAAM,cAAc,EAAE,OAAO,IAAI,IAAI,eAAe,aAAa,sBAAsB,KAAK,MAAM,CAAC;AAC1G;AACA,sBAAY,IAAI;AAChB,cAAI,iBAAiB,sBAAsB;AACzC,mBAAO,MAAM,kEAAkE,EAAE,OAAO,IAAI,IAAI,aAAa,qBAAqB,CAAC;AACnI,uBAAW,IAAI,IAAI,EAAE;AAAA,UACvB,OAAO;AACL,kBAAM,YAAY,wBAAwB,KAAK,IAAI,GAAG,gBAAgB,CAAC;AACvE,yBAAa,IAAI,IAAI,IAAI;AAAA,cACvB,GAAG;AAAA,cACH,cAAc;AAAA,cACd,aAAa,IAAI,KAAK,KAAK,IAAI,IAAI,SAAS,EAAE,YAAY;AAAA,YAC5D,CAAC;AAAA,UACH;AAAA,QACF;AAAA,MACF;AAEA,YAAM,aAAa,gBAAgB,OAAO,KAAK,WAAW,OAAO,KAAK,aAAa,OAAO;AAC1F,UAAI,YAAY;AACd,cAAM,aAAa,YAAY;AAE7B,gBAAM,cAAc,MAAM,UAAU;AACpC,gBAAM,cAAc,YACjB,OAAO,CAAC,MAAM,CAAC,gBAAgB,IAAI,EAAE,EAAE,KAAK,CAAC,WAAW,IAAI,EAAE,EAAE,CAAC,EACjE,IAAI,CAAC,MAAM,aAAa,IAAI,EAAE,EAAE,KAAK,CAAC;AACzC,gBAAM,WAAW,WAAW;AAE5B,gBAAM,WAAuB;AAAA,YAC3B,iBAAiB;AAAA,YACjB,iBAAiB,MAAM,kBAAkB,KAAK;AAAA,YAC9C,cAAc,MAAM,eAAe,KAAK,WAAW;AAAA,UACrD;AACA,gBAAM,WAAW,QAAQ;AAAA,QAC3B,CAAC;AAAA,MACH;AAEA,aAAO,EAAE,WAAW,QAAQ,UAAU;AAAA,IACxC,UAAE;AACA,iBAAW,OAAO,eAAe;AAC/B,uBAAe,OAAO,IAAI,EAAE;AAAA,MAC9B;AAAA,IACF;AAAA,EACF;AAKA,iBAAe,iBAAgC;AAE7C,QAAI,gBAAgB,CAAC,cAAe;AAEpC,mBAAe;AACf,QAAI;AACF,YAAM,aAAa,aAAa;AAAA,IAClC,SAAS,OAAO;AACd,aAAO,MAAM,iBAAiB,EAAE,KAAK,MAAM,CAAC;AAAA,IAC9C,UAAE;AACA,qBAAe;AAAA,IACjB;AAAA,EACF;AAEA,iBAAe,QACb,SACAA,UACwB;AAExB,QAAIA,UAAS,OAAO;AAClB,aAAO,aAAa,SAASA,QAAO;AAAA,IACtC;AAGA,oBAAgB;AAGhB,UAAM,aAAa,OAAO;AAG1B,mBAAe,YAAY,MAAM;AAC/B,qBAAe,EAAE,MAAM,CAAC,QAAQ;AAC9B,eAAO,MAAM,oBAAoB,EAAE,IAAI,CAAC;AAAA,MAC1C,CAAC;AAAA,IACH,GAAG,YAAY;AAEf,WAAO,KAAK,kBAAkB,EAAE,YAAY,CAAC;AAG7C,WAAO,EAAE,WAAW,IAAI,QAAQ,IAAI,WAAW,OAAU;AAAA,EAC3D;AAEA,iBAAe,QAAsC;AACnD,WAAO,aAAa,YAAY;AAC9B,YAAM,OAAO,MAAM,UAAU;AAC7B,YAAM,UAAU,KAAK;AACrB,YAAM,WAAW,CAAC,CAAC;AAEnB,YAAM,QAAQ,MAAM,UAAU;AAC9B,YAAM,WAAW;AAAA,QACf,gBAAgB,MAAM;AAAA,QACtB,aAAa,MAAM;AAAA,MACrB,CAAC;AACD,aAAO,EAAE,QAAQ;AAAA,IACnB,CAAC;AAAA,EACH;AAEA,iBAAe,wBAAwB,OAAoD;AACzF,WAAO,aAAa,YAAY;AAC9B,YAAM,OAAO,MAAM,UAAU;AAC7B,YAAM,eAAe,KAAK,OAAO,CAAC,QAAQ,eAAe,IAAI,IAAI,EAAE,KAAK,CAAC,oBAAoB,IAAI,SAAS,KAAK,CAAC;AAChH,YAAM,UAAU,KAAK,SAAS,aAAa;AAC3C,UAAI,UAAU,GAAG;AACf,cAAM,WAAW,YAAY;AAAA,MAC/B;AACA,aAAO,EAAE,QAAQ;AAAA,IACnB,CAAC;AAAA,EACH;AAEA,iBAAe,QAAuB;AAEpC,QAAI,cAAc;AAChB,oBAAc,YAAY;AAC1B,qBAAe;AAAA,IACjB;AACA,oBAAgB;AAGhB,UAAM,mBAAmB;AACzB,UAAM,YAAY,KAAK,IAAI;AAE3B,WAAO,cAAc;AACnB,UAAI,KAAK,IAAI,IAAI,YAAY,kBAAkB;AAC7C,eAAO,KAAK,wCAAwC,EAAE,WAAW,iBAAiB,CAAC;AACnF;AAAA,MACF;AACA,YAAM,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,EAAE,CAAC;AAAA,IACxD;AAAA,EACF;AAEA,iBAAe,eAKZ;AACD,WAAO,aAAa,YAAY;AAC9B,YAAM,QAAQ,MAAM,UAAU;AAC9B,YAAM,OAAO,MAAM,UAAU;AAE7B,aAAO;AAAA,QACL,SAAS,KAAK;AAAA;AAAA,QACd,QAAQ;AAAA;AAAA,QACR,WAAW,MAAM,kBAAkB;AAAA,QACnC,QAAQ,MAAM,eAAe;AAAA,MAC/B;AAAA,IACF,CAAC;AAAA,EACH;AAEA,SAAO;AAAA,IACL;AAAA,IACA,UAAU;AAAA,IACV;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import fs from 'node:fs'\nimport path from 'node:path'\nimport crypto from 'node:crypto'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { Queue, QueuedJob, JobHandler, LocalQueueOptions, ProcessOptions, ProcessResult, EnqueueOptions, QueueJobScope } from '../types'\nimport { attachTraceMetadata, runJobInTrace } from '../tracing'\n\nconst packageLogger = createLogger('queue')\n\ntype LocalState = {\n lastProcessedId?: string\n completedCount?: number\n failedCount?: number\n}\n\ntype StoredJob<T> = QueuedJob<T> & {\n availableAt?: string\n attemptCount?: number\n}\n\ntype QueueFileIdentity = {\n device: number\n inode: number\n}\n\nfunction payloadMatchesScope(payload: unknown, scope: QueueJobScope): boolean {\n if (!payload || typeof payload !== 'object') return false\n const scopedPayload = payload as { tenantId?: unknown; organizationId?: unknown; jobType?: unknown }\n if (scopedPayload.tenantId !== scope.tenantId) return false\n if (scope.organizationId !== undefined) {\n if ((scopedPayload.organizationId ?? null) !== scope.organizationId) return false\n }\n if (scope.jobTypes?.length) {\n return typeof scopedPayload.jobType === 'string' && scope.jobTypes.includes(scopedPayload.jobType)\n }\n return true\n}\n\n/** Polling interval while delayed or retrying work remains queued. */\nconst DEFAULT_POLL_INTERVAL = 1000\n/** Idle safety interval for missed filesystem watcher events. */\nconst DEFAULT_FALLBACK_POLL_INTERVAL = 5000\nconst DEFAULT_LOCAL_QUEUE_BASE_DIR = '.mercato/queue'\nconst DEFAULT_MAX_ATTEMPTS = 3\nconst RETRY_BACKOFF_BASE_MS = 1000\n\n/**\n * Cross-process lock tuning. A held lock only ever spans local file I/O \u2014 job\n * handlers run outside it \u2014 so realistic hold times are milliseconds and the\n * stale threshold sits orders of magnitude above them. It exists solely so a\n * process that dies mid-segment cannot wedge the queue forever. A holder that\n * was merely suspended rather than dead can still be reclaimed, which is why\n * every acquisition carries an owner token and releases only its own lock.\n */\nconst LOCK_STALE_MS = 15_000\nconst LOCK_ACQUIRE_TIMEOUT_MS = 30_000\nconst LOCK_RETRY_MIN_MS = 2\nconst LOCK_RETRY_MAX_MS = 20\nconst RENAME_MAX_RETRIES = 5\nconst RENAME_RETRY_BASE_MS = 10\n\nconst fsp = fs.promises\n\n/**\n * Creates a file-based local queue.\n *\n * Jobs are stored in JSON files within a directory structure:\n * - `.mercato/queue/<name>/queue.json` - Array of queued jobs\n * - `.mercato/queue/<name>/state.json` - Processing state (last processed ID)\n *\n * **Limitations:**\n * - Jobs are processed sequentially (concurrency option is for logging/compatibility only)\n * - Not suitable for production: there is no dead-letter store, no throughput\n * beyond one job at a time, and every operation rewrites the whole queue file\n *\n * Multiple processes MAY share a queue directory, which is the default\n * development topology: the dev worker runs in its own process alongside the\n * Next.js server. What that buys you, and what it does not:\n *\n * - **Safe** \u2014 concurrent producers. Every read-modify-write segment takes the\n * `queue.lock` directory lock and every persist swaps the file in with an\n * atomic rename, so the file cannot be torn, no enqueue is lost to a\n * concurrent one, and a reader always observes one complete document.\n * Writers contend, though, so throughput degrades as processes are added.\n * - **NOT safe** \u2014 concurrent consumers. `process()` deliberately runs job\n * handlers outside the lock, so two worker processes polling the same queue\n * would both claim the same pending jobs and execute them twice. There is no\n * per-job lease. Run exactly one worker process per queue; use the `async`\n * strategy when you need more than one.\n *\n * Failed jobs are retried up to `DEFAULT_MAX_ATTEMPTS` times with exponential backoff.\n * **This strategy keeps no failed-job store**: once attempts are exhausted the job is\n * removed from `queue.json` and only counted in `state.failedCount`, so the payload is\n * lost and the failure survives solely as an error log line. The `async` strategy keeps\n * only a bounded inspection window: `removeOnFail: 1000` retains the most recent 1000\n * failures and removes older ones as later failures arrive. Workflows that require\n * no-loss persistence must write their own durable record before enqueueing, regardless\n * of strategy.\n *\n * `DEFAULT_MAX_ATTEMPTS` is a module constant, not a per-job option \u2014 callers cannot\n * request a different attempt count. (`async` likewise hard-codes `attempts: 3`.)\n * See the retry handling in `process()` below.\n *\n * All file I/O is asynchronous (`fs.promises.*`) so queue operations do not\n * block the Node.js event loop. A per-queue promise chain serializes\n * read-modify-write sequences within one instance, and the `queue.lock`\n * directory lock extends that serialization across instances and processes.\n *\n * @template T - The payload type for jobs\n * @param name - Queue name (used for directory naming)\n * @param options - Local queue options\n */\nexport function createLocalQueue<T = unknown>(\n name: string,\n options?: LocalQueueOptions\n): Queue<T> {\n const nodeProcess = (globalThis as typeof globalThis & { process?: NodeJS.Process }).process\n const queueBaseDirFromEnv = nodeProcess?.env?.QUEUE_BASE_DIR\n const baseDir = options?.baseDir\n ?? path.resolve(queueBaseDirFromEnv || DEFAULT_LOCAL_QUEUE_BASE_DIR)\n const queueDir = path.join(baseDir, name)\n const queueFile = path.join(queueDir, 'queue.json')\n const stateFile = path.join(queueDir, 'state.json')\n const lockDir = path.join(queueDir, 'queue.lock')\n const lockOwnerFile = path.join(lockDir, 'owner')\n const logger = packageLogger.child({ queue: name })\n // Note: concurrency is stored for logging/compatibility but jobs are processed sequentially\n const concurrency = options?.concurrency ?? 1\n const pollInterval = options?.pollInterval ?? DEFAULT_POLL_INTERVAL\n const fallbackPollInterval = Math.max(pollInterval, DEFAULT_FALLBACK_POLL_INTERVAL)\n\n // Worker state for continuous polling\n let pollingTimer: ReturnType<typeof setInterval> | null = null\n let queuedPollTimer: ReturnType<typeof setTimeout> | null = null\n let queueWatcher: fs.FSWatcher | null = null\n let queueWatcherIdentity: QueueFileIdentity | null = null\n let watcherRefreshChain: Promise<void> = Promise.resolve()\n let hasQueuedJobs = false\n let isProcessing = false\n let pollRequested = false\n let activeHandler: JobHandler<T> | null = null\n const inFlightJobIds = new Set<string>()\n\n // Per-queue mutex. Serializes read-modify-write segments so async fs calls\n // cannot interleave and clobber each other's writes. It only covers this\n // instance, so it also guarantees at most one outstanding `queue.lock`\n // acquisition per instance \u2014 the directory lock below is not reentrant.\n let fileOpChain: Promise<unknown> = Promise.resolve()\n function withFileLock<R>(fn: () => Promise<R>): Promise<R> {\n const run = fileOpChain.then(\n () => runExclusively(fn),\n () => runExclusively(fn),\n )\n fileOpChain = run.then(\n () => undefined,\n () => undefined,\n )\n return run\n }\n\n /**\n * Runs `fn` while holding the cross-process `queue.lock`, so read-modify-write\n * segments issued by other queue instances \u2014 in this process or another one \u2014\n * cannot interleave with it.\n */\n async function runExclusively<R>(fn: () => Promise<R>): Promise<R> {\n await ensureDir()\n const release = await acquireDirectoryLock()\n try {\n return await fn()\n } finally {\n await release()\n }\n }\n\n // -------------------------------------------------------------------------\n // File Operations\n // -------------------------------------------------------------------------\n\n function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => { setTimeout(resolve, ms) })\n }\n\n async function lockHeldForMs(): Promise<number | null> {\n try {\n const stats = await fsp.stat(lockDir)\n return Date.now() - stats.mtimeMs\n } catch {\n return null\n }\n }\n\n /**\n * Reclaims a lock whose holder died. The rename is the serialization point:\n * only one racer can move `queue.lock` aside, so two processes cannot both\n * decide a stale lock is theirs to clear and then both create a fresh one.\n */\n async function reclaimStaleLock(heldForMs: number): Promise<void> {\n const reclaimedPath = `${lockDir}.stale.${crypto.randomUUID()}`\n try {\n await fsp.rename(lockDir, reclaimedPath)\n } catch {\n return\n }\n logger.warn('Reclaimed a stale queue lock', { lockDir, heldForMs })\n await fsp.rm(reclaimedPath, { recursive: true, force: true }).catch(() => {})\n }\n\n async function readLockOwner(): Promise<string | null> {\n try {\n return await fsp.readFile(lockOwnerFile, 'utf8')\n } catch {\n return null\n }\n }\n\n /**\n * Releases the lock only when this acquisition still owns it. A holder that\n * was suspended past `LOCK_STALE_MS` has had its lock reclaimed *and\n * replaced* by whoever reclaimed it, so an unconditional removal here would\n * delete the successor's lock and let a third caller into the critical\n * section alongside it. A missing or mismatched token means someone else owns\n * the path now, and the correct action is to leave it alone.\n */\n async function releaseDirectoryLock(token: string): Promise<void> {\n if (await readLockOwner() !== token) return\n await fsp.rm(lockDir, { recursive: true, force: true }).catch(() => {})\n }\n\n /**\n * Acquires the cross-process advisory lock for this queue directory.\n * `mkdir` without `recursive` is an atomic exclusive create on every platform\n * Node.js supports, which makes it the portable primitive here \u2014 no runtime\n * dependency, and no reliance on advisory `flock` semantics. The owner token\n * written into the directory is what lets the release distinguish this\n * acquisition from a successor's.\n */\n async function acquireDirectoryLock(): Promise<() => Promise<void>> {\n const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS\n\n for (;;) {\n let acquired = false\n try {\n await fsp.mkdir(lockDir)\n acquired = true\n } catch (e: unknown) {\n const error = e as NodeJS.ErrnoException\n if (error.code !== 'EEXIST') throw error\n }\n\n if (acquired) {\n const token = crypto.randomUUID()\n try {\n await fsp.writeFile(lockOwnerFile, token, 'utf8')\n } catch (error: unknown) {\n await fsp.rm(lockDir, { recursive: true, force: true }).catch(() => {})\n throw error\n }\n return () => releaseDirectoryLock(token)\n }\n\n const heldForMs = await lockHeldForMs()\n if (heldForMs !== null && heldForMs > LOCK_STALE_MS) {\n await reclaimStaleLock(heldForMs)\n continue\n }\n\n if (Date.now() >= deadline) {\n throw new Error(\n `[internal] Timed out after ${LOCK_ACQUIRE_TIMEOUT_MS}ms waiting for the queue lock at ${lockDir}`,\n )\n }\n\n const jitter = LOCK_RETRY_MIN_MS + Math.random() * (LOCK_RETRY_MAX_MS - LOCK_RETRY_MIN_MS)\n await sleep(jitter)\n }\n }\n\n /**\n * Persists `content` by writing a unique sibling temp file and renaming it\n * onto `targetFile`. `rename` within a directory is atomic, so a concurrent\n * reader sees either the previous document or the new one in full \u2014 never the\n * torn result of a truncate-then-write.\n */\n async function writeFileAtomic(targetFile: string, content: string): Promise<void> {\n const tempFile = `${targetFile}.${crypto.randomUUID()}.tmp`\n try {\n await fsp.writeFile(tempFile, content, 'utf8')\n await renameWithContentionRetry(tempFile, targetFile)\n } catch (error: unknown) {\n await fsp.rm(tempFile, { force: true }).catch(() => {})\n throw error\n }\n }\n\n /**\n * Windows rejects a rename onto a file another process currently has open,\n * so retry briefly on the contention codes it raises. POSIX renames replace\n * the target unconditionally and take the first attempt.\n */\n async function renameWithContentionRetry(fromFile: string, toFile: string): Promise<void> {\n const contentionCodes = new Set(['EPERM', 'EBUSY', 'EACCES'])\n for (let attempt = 0; ; attempt++) {\n try {\n await fsp.rename(fromFile, toFile)\n return\n } catch (e: unknown) {\n const error = e as NodeJS.ErrnoException\n if (attempt >= RENAME_MAX_RETRIES || !error.code || !contentionCodes.has(error.code)) throw error\n await sleep(RENAME_RETRY_BASE_MS * (attempt + 1))\n }\n }\n }\n\n async function ensureDir(): Promise<void> {\n try {\n await fsp.mkdir(queueDir, { recursive: true })\n } catch (e: unknown) {\n const error = e as NodeJS.ErrnoException\n if (error.code !== 'EEXIST') throw error\n }\n\n // Initialize queue file with exclusive create flag\n try {\n await fsp.writeFile(queueFile, '[]', { encoding: 'utf8', flag: 'wx' })\n } catch (e: unknown) {\n const error = e as NodeJS.ErrnoException\n if (error.code !== 'EEXIST') throw error\n }\n\n // Initialize state file with exclusive create flag\n try {\n await fsp.writeFile(stateFile, '{}', { encoding: 'utf8', flag: 'wx' })\n } catch (e: unknown) {\n const error = e as NodeJS.ErrnoException\n if (error.code !== 'EEXIST') throw error\n }\n }\n\n /**\n * Moves an unparsable queue file aside so its jobs stay recoverable. The\n * caller is expected to surface the failure rather than continue on an empty\n * queue: silently recreating `queue.json` here is what turned an unreadable\n * file into permanent, unreported job loss.\n */\n async function quarantineCorruptedQueueFile(): Promise<string | null> {\n const backupFile = path.join(queueDir, `queue.corrupted.${Date.now()}.${crypto.randomUUID()}.json`)\n try {\n await fsp.rename(queueFile, backupFile)\n return backupFile\n } catch (e: unknown) {\n logger.error('Failed to quarantine the corrupted queue file', { err: e as Error })\n return null\n }\n }\n\n async function readQueue(): Promise<StoredJob<T>[]> {\n await ensureDir()\n let content: string\n\n try {\n content = await fsp.readFile(queueFile, 'utf8')\n } catch (error: unknown) {\n const readError = error as NodeJS.ErrnoException\n if (readError.code === 'ENOENT') {\n return []\n }\n logger.error('Failed to read queue file', { err: readError })\n throw new Error(`Queue file unreadable: ${readError.message}`)\n }\n\n try {\n const parsed = JSON.parse(content) as unknown\n\n if (!Array.isArray(parsed)) {\n throw new Error('Queue file must contain a JSON array')\n }\n\n return parsed as StoredJob<T>[]\n } catch (error: unknown) {\n const parseError = error as Error\n logger.error('Failed to parse queue file', { err: parseError })\n const backupFile = await quarantineCorruptedQueueFile()\n if (backupFile) {\n logger.error('Quarantined corrupted queue file; its jobs are recoverable from the backup', { backupFile })\n }\n const recoveryHint = backupFile\n ? `has been quarantined as ${backupFile}`\n : 'could not be quarantined and was left in place'\n throw new Error(\n `[internal] Queue file ${queueFile} was unparsable and ${recoveryHint}: ${parseError.message}`,\n )\n }\n }\n\n async function writeQueue(jobs: StoredJob<T>[]): Promise<void> {\n await ensureDir()\n await writeFileAtomic(queueFile, JSON.stringify(jobs, null, 2))\n }\n\n async function readState(): Promise<LocalState> {\n await ensureDir()\n try {\n const content = await fsp.readFile(stateFile, 'utf8')\n return JSON.parse(content) as LocalState\n } catch {\n return {}\n }\n }\n\n async function writeState(state: LocalState): Promise<void> {\n await ensureDir()\n await writeFileAtomic(stateFile, JSON.stringify(state, null, 2))\n }\n\n function generateId(): string {\n return crypto.randomUUID()\n }\n\n // -------------------------------------------------------------------------\n // Queue Implementation\n // -------------------------------------------------------------------------\n\n async function enqueue(data: T, options?: EnqueueOptions): Promise<string> {\n const availableAt = options?.delayMs && options.delayMs > 0\n ? new Date(Date.now() + options.delayMs).toISOString()\n : undefined\n const metadata = attachTraceMetadata(undefined)\n const job: StoredJob<T> = {\n id: generateId(),\n payload: data,\n createdAt: new Date().toISOString(),\n ...(availableAt ? { availableAt } : {}),\n ...(metadata ? { metadata } : {}),\n }\n await withFileLock(async () => {\n const jobs = await readQueue()\n jobs.push(job)\n await writeQueue(jobs)\n })\n return job.id\n }\n\n /**\n * Process pending jobs in a single batch (internal helper).\n */\n async function processBatch(\n handler: JobHandler<T>,\n options?: ProcessOptions\n ): Promise<ProcessResult> {\n const { state, jobs } = await withFileLock(async () => {\n const stateRead = await readState()\n const jobsRead = await readQueue()\n return { state: stateRead, jobs: jobsRead }\n })\n hasQueuedJobs = jobs.length > 0\n\n const pendingJobs = jobs.filter((job) => {\n if (!job.availableAt) return true\n return new Date(job.availableAt).getTime() <= Date.now()\n })\n const jobsToProcess = options?.limit\n ? pendingJobs.slice(0, options.limit)\n : pendingJobs\n\n for (const job of jobsToProcess) {\n inFlightJobIds.add(job.id)\n }\n\n let processed = 0\n let failed = 0\n let lastJobId: string | undefined\n const completedJobIds = new Set<string>()\n const deadJobIds = new Set<string>()\n const retryUpdates = new Map<string, StoredJob<T>>()\n\n try {\n for (const job of jobsToProcess) {\n const attemptNumber = (job.attemptCount ?? 0) + 1\n try {\n await runJobInTrace(name, job.metadata, () =>\n Promise.resolve(\n handler(job, {\n jobId: job.id,\n attemptNumber,\n queueName: name,\n })\n )\n )\n processed++\n lastJobId = job.id\n completedJobIds.add(job.id)\n logger.info('Job completed', { jobId: job.id })\n } catch (error) {\n logger.error('Job failed', { jobId: job.id, attemptNumber, maxAttempts: DEFAULT_MAX_ATTEMPTS, err: error })\n failed++\n lastJobId = job.id\n if (attemptNumber >= DEFAULT_MAX_ATTEMPTS) {\n logger.error('Job exhausted all attempts; dropping it (no dead-letter store)', { jobId: job.id, maxAttempts: DEFAULT_MAX_ATTEMPTS })\n deadJobIds.add(job.id)\n } else {\n const backoffMs = RETRY_BACKOFF_BASE_MS * Math.pow(2, attemptNumber - 1)\n retryUpdates.set(job.id, {\n ...job,\n attemptCount: attemptNumber,\n availableAt: new Date(Date.now() + backoffMs).toISOString(),\n })\n }\n }\n }\n\n const hasChanges = completedJobIds.size > 0 || deadJobIds.size > 0 || retryUpdates.size > 0\n if (hasChanges) {\n await withFileLock(async () => {\n // Re-read so jobs enqueued during handler execution are preserved.\n const currentJobs = await readQueue()\n const updatedJobs = currentJobs\n .filter((j) => !completedJobIds.has(j.id) && !deadJobIds.has(j.id))\n .map((j) => retryUpdates.get(j.id) ?? j)\n await writeQueue(updatedJobs)\n hasQueuedJobs = updatedJobs.length > 0\n\n const newState: LocalState = {\n lastProcessedId: lastJobId,\n completedCount: (state.completedCount ?? 0) + processed,\n failedCount: (state.failedCount ?? 0) + deadJobIds.size,\n }\n await writeState(newState)\n })\n }\n\n return { processed, failed, lastJobId }\n } finally {\n for (const job of jobsToProcess) {\n inFlightJobIds.delete(job.id)\n }\n }\n }\n\n /**\n * Poll for and process new jobs.\n */\n async function pollAndProcess(rethrow = false): Promise<void> {\n if (!activeHandler) return\n if (isProcessing) {\n pollRequested = true\n return\n }\n\n isProcessing = true\n try {\n do {\n pollRequested = false\n const handler = activeHandler\n if (!handler) break\n await processBatch(handler)\n } while (pollRequested)\n } catch (error) {\n if (rethrow) throw error\n logger.error('Polling error', { err: error })\n } finally {\n isProcessing = false\n scheduleQueuedPoll()\n }\n }\n\n function scheduleQueuedPoll(): void {\n if (!activeHandler || !hasQueuedJobs) {\n if (queuedPollTimer) {\n clearTimeout(queuedPollTimer)\n queuedPollTimer = null\n }\n return\n }\n if (queuedPollTimer) return\n queuedPollTimer = setTimeout(() => {\n queuedPollTimer = null\n void pollAndProcess()\n }, pollInterval)\n }\n\n function closeQueueWatcher(): void {\n if (queueWatcher) {\n queueWatcher.close()\n queueWatcher = null\n }\n queueWatcherIdentity = null\n }\n\n function refreshQueueWatcher(): Promise<void> {\n const refresh = watcherRefreshChain.then(async () => {\n if (!activeHandler) return\n try {\n await ensureDir()\n const stats = await fsp.stat(queueFile)\n const nextIdentity = { device: stats.dev, inode: stats.ino }\n if (\n queueWatcher\n && queueWatcherIdentity?.device === nextIdentity.device\n && queueWatcherIdentity.inode === nextIdentity.inode\n ) {\n return\n }\n\n closeQueueWatcher()\n const watcher = fs.watch(queueFile, (eventType) => {\n if (eventType === 'rename') {\n queueWatcherIdentity = null\n void refreshQueueWatcher()\n }\n void pollAndProcess()\n })\n watcher.on('error', (err) => {\n logger.error('Queue watch error; fallback polling remains active', { err })\n if (queueWatcher === watcher) {\n closeQueueWatcher()\n }\n })\n if (!activeHandler) {\n watcher.close()\n return\n }\n queueWatcher = watcher\n queueWatcherIdentity = nextIdentity\n } catch (err) {\n logger.error('Failed to watch queue file; fallback polling remains active', { err })\n }\n })\n watcherRefreshChain = refresh.catch(() => undefined)\n return refresh\n }\n\n async function process(\n handler: JobHandler<T>,\n options?: ProcessOptions\n ): Promise<ProcessResult> {\n // If limit is specified, do a single batch (backward compatibility)\n if (options?.limit) {\n return processBatch(handler, options)\n }\n\n if (activeHandler) {\n await close()\n }\n\n // Start continuous polling mode (like BullMQ Worker)\n activeHandler = handler\n\n try {\n await refreshQueueWatcher()\n await pollAndProcess(true)\n } catch (error) {\n await close()\n throw error\n }\n\n pollingTimer = setInterval(() => {\n refreshQueueWatcher().then(() => pollAndProcess()).catch((err) => {\n logger.error('Poll cycle error', { err })\n })\n }, fallbackPollInterval)\n\n logger.info('Worker started', { concurrency })\n\n // Return sentinel value indicating continuous worker mode (like async strategy)\n return { processed: -1, failed: -1, lastJobId: undefined }\n }\n\n async function clear(): Promise<{ removed: number }> {\n return withFileLock(async () => {\n const jobs = await readQueue()\n const removed = jobs.length\n await writeQueue([])\n hasQueuedJobs = false\n scheduleQueuedPoll()\n // Reset state but preserve counts for historical tracking\n const state = await readState()\n await writeState({\n completedCount: state.completedCount,\n failedCount: state.failedCount,\n })\n return { removed }\n })\n }\n\n async function removeQueuedJobsByScope(scope: QueueJobScope): Promise<{ removed: number }> {\n return withFileLock(async () => {\n const jobs = await readQueue()\n const retainedJobs = jobs.filter((job) => inFlightJobIds.has(job.id) || !payloadMatchesScope(job.payload, scope))\n const removed = jobs.length - retainedJobs.length\n if (removed > 0) {\n await writeQueue(retainedJobs)\n }\n hasQueuedJobs = retainedJobs.length > 0\n scheduleQueuedPoll()\n return { removed }\n })\n }\n\n async function close(): Promise<void> {\n activeHandler = null\n if (queuedPollTimer) {\n clearTimeout(queuedPollTimer)\n queuedPollTimer = null\n }\n closeQueueWatcher()\n\n // Stop polling timer\n if (pollingTimer) {\n clearInterval(pollingTimer)\n pollingTimer = null\n }\n // Wait for any in-progress processing to complete (with timeout)\n const SHUTDOWN_TIMEOUT = 5000\n const startTime = Date.now()\n\n while (isProcessing) {\n if (Date.now() - startTime > SHUTDOWN_TIMEOUT) {\n logger.warn('Force closing after shutdown timeout', { timeoutMs: SHUTDOWN_TIMEOUT })\n break\n }\n await new Promise((resolve) => setTimeout(resolve, 50))\n }\n }\n\n async function getJobCounts(): Promise<{\n waiting: number\n active: number\n completed: number\n failed: number\n }> {\n return withFileLock(async () => {\n const state = await readState()\n const jobs = await readQueue()\n\n return {\n waiting: jobs.length, // All jobs in queue are waiting (processed ones are removed)\n active: 0, // Local strategy doesn't track active jobs\n completed: state.completedCount ?? 0,\n failed: state.failedCount ?? 0,\n }\n })\n }\n\n return {\n name,\n strategy: 'local',\n enqueue,\n process,\n clear,\n removeQueuedJobsByScope,\n close,\n getJobCounts,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["options"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/queue",
3
- "version": "0.6.8-develop.6948.1.8369fc4c97",
3
+ "version": "0.6.8-develop.6958.1.6696e8db69",
4
4
  "license": "MIT",
5
5
  "description": "Multi-strategy job queue with local and BullMQ support",
6
6
  "type": "module",
@@ -56,8 +56,8 @@
56
56
  "access": "public"
57
57
  },
58
58
  "dependencies": {
59
- "@open-mercato/shared": "0.6.8-develop.6948.1.8369fc4c97",
60
- "@open-mercato/telemetry": "0.6.8-develop.6948.1.8369fc4c97"
59
+ "@open-mercato/shared": "0.6.8-develop.6958.1.6696e8db69",
60
+ "@open-mercato/telemetry": "0.6.8-develop.6958.1.6696e8db69"
61
61
  },
62
62
  "repository": {
63
63
  "type": "git",
@@ -22,6 +22,29 @@ const queueLoggerError = createLogger('queue').error as jest.Mock
22
22
 
23
23
  function readJson(p: string) { return JSON.parse(fs.readFileSync(p, 'utf8')) }
24
24
 
25
+ async function within<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
26
+ let timeout: ReturnType<typeof setTimeout> | undefined
27
+ try {
28
+ return await Promise.race([
29
+ promise,
30
+ new Promise<T>((_resolve, reject) => {
31
+ timeout = setTimeout(() => reject(new Error(`[internal] Timed out after ${timeoutMs}ms`)), timeoutMs)
32
+ }),
33
+ ])
34
+ } finally {
35
+ if (timeout) clearTimeout(timeout)
36
+ }
37
+ }
38
+
39
+ function createWatcherStub(): fs.FSWatcher {
40
+ const watcher = {
41
+ close: jest.fn(),
42
+ on: jest.fn(),
43
+ }
44
+ watcher.on.mockReturnValue(watcher)
45
+ return watcher as unknown as fs.FSWatcher
46
+ }
47
+
25
48
  async function waitUntil(condition: () => boolean, timeoutMs = 5000): Promise<void> {
26
49
  const deadline = Date.now() + timeoutMs
27
50
  while (!condition()) {
@@ -622,4 +645,350 @@ describe('Queue - local strategy', () => {
622
645
 
623
646
  await queue.close()
624
647
  })
648
+
649
+ test('continuous workers process new jobs without waiting for the polling interval', async () => {
650
+ const baseDir = path.join(tmp, 'event-wakeup')
651
+ const producer = createQueue<{ value: number }>('event-wakeup', 'local', { baseDir })
652
+ const consumer = createQueue<{ value: number }>('event-wakeup', 'local', { baseDir })
653
+ let resolveProcessed!: (value: number) => void
654
+ const processed = new Promise<number>((resolve) => {
655
+ resolveProcessed = resolve
656
+ })
657
+
658
+ try {
659
+ await consumer.process((job) => {
660
+ resolveProcessed(job.payload.value)
661
+ })
662
+
663
+ await producer.enqueue({ value: 42 })
664
+
665
+ await expect(within(processed, 800)).resolves.toBe(42)
666
+ } finally {
667
+ await consumer.close()
668
+ await producer.close()
669
+ }
670
+ })
671
+
672
+ test('idle continuous workers do not poll at the queued-work default interval', async () => {
673
+ jest.useFakeTimers()
674
+ const baseDir = path.join(tmp, 'idle-default')
675
+ const queueFile = path.join(baseDir, 'idle-default', 'queue.json')
676
+ const watcher = createWatcherStub()
677
+ const watchSpy = jest.spyOn(fs, 'watch').mockReturnValue(watcher)
678
+ const readFileSpy = jest.spyOn(fs.promises, 'readFile')
679
+ const consumer = createQueue<{ value: number }>('idle-default', 'local', { baseDir })
680
+
681
+ try {
682
+ await consumer.process(() => {})
683
+ readFileSpy.mockClear()
684
+
685
+ await jest.advanceTimersByTimeAsync(1500)
686
+ jest.useRealTimers()
687
+ await new Promise((resolve) => setTimeout(resolve, 50))
688
+
689
+ const queueReads = readFileSpy.mock.calls.filter(([filePath]) => String(filePath) === queueFile)
690
+ expect(queueReads).toHaveLength(0)
691
+ } finally {
692
+ jest.useRealTimers()
693
+ await consumer.close()
694
+ readFileSpy.mockRestore()
695
+ watchSpy.mockRestore()
696
+ }
697
+ })
698
+
699
+ test('custom queued-work polling keeps the idle safety interval', async () => {
700
+ jest.useFakeTimers()
701
+ const baseDir = path.join(tmp, 'idle-custom')
702
+ const queueFile = path.join(baseDir, 'idle-custom', 'queue.json')
703
+ const watcher = createWatcherStub()
704
+ const watchSpy = jest.spyOn(fs, 'watch').mockReturnValue(watcher)
705
+ const readFileSpy = jest.spyOn(fs.promises, 'readFile')
706
+ const consumer = createQueue<{ value: number }>('idle-custom', 'local', {
707
+ baseDir,
708
+ pollInterval: 50,
709
+ })
710
+
711
+ try {
712
+ await consumer.process(() => {})
713
+ readFileSpy.mockClear()
714
+
715
+ await jest.advanceTimersByTimeAsync(500)
716
+ jest.useRealTimers()
717
+ await new Promise((resolve) => setTimeout(resolve, 50))
718
+
719
+ const queueReads = readFileSpy.mock.calls.filter(([filePath]) => String(filePath) === queueFile)
720
+ expect(queueReads).toHaveLength(0)
721
+ } finally {
722
+ jest.useRealTimers()
723
+ await consumer.close()
724
+ readFileSpy.mockRestore()
725
+ watchSpy.mockRestore()
726
+ }
727
+ })
728
+
729
+ test('continuous workers re-arm filesystem wake-ups after the queue directory is recreated', async () => {
730
+ jest.useFakeTimers()
731
+ const baseDir = path.join(tmp, 'recreated-queue')
732
+ const movedDir = path.join(tmp, 'moved-queue')
733
+ const consumer = createQueue<{ value: number }>('recreated-queue', 'local', { baseDir })
734
+ let resolveRecovered!: (value: number) => void
735
+ const recovered = new Promise<number>((resolve) => {
736
+ resolveRecovered = resolve
737
+ })
738
+ let resolveEventDriven!: (value: number) => void
739
+ const eventDriven = new Promise<number>((resolve) => {
740
+ resolveEventDriven = resolve
741
+ })
742
+
743
+ try {
744
+ await consumer.process((job) => {
745
+ if (job.payload.value === 7) {
746
+ resolveRecovered(job.payload.value)
747
+ return
748
+ }
749
+ resolveEventDriven(job.payload.value)
750
+ })
751
+ fs.renameSync(baseDir, movedDir)
752
+ const producer = createQueue<{ value: number }>('recreated-queue', 'local', { baseDir })
753
+
754
+ try {
755
+ await producer.enqueue({ value: 7 })
756
+ const recoveredWithinFallback = within(recovered, 5500)
757
+ await jest.advanceTimersByTimeAsync(5000)
758
+ await expect(recoveredWithinFallback).resolves.toBe(7)
759
+
760
+ jest.useRealTimers()
761
+ await within((async () => {
762
+ while (true) {
763
+ const counts = await consumer.getJobCounts()
764
+ if (counts.completed === 1 && counts.waiting === 0) break
765
+ await new Promise((resolve) => setTimeout(resolve, 10))
766
+ }
767
+ })(), 800)
768
+ await producer.enqueue({ value: 8 })
769
+ await expect(within(eventDriven, 800)).resolves.toBe(8)
770
+ } finally {
771
+ await producer.close()
772
+ }
773
+ } finally {
774
+ jest.useRealTimers()
775
+ await consumer.close()
776
+ }
777
+ })
778
+
779
+ test('clear cancels queued-work polling after draining the queue', async () => {
780
+ jest.useFakeTimers()
781
+ const baseDir = path.join(tmp, 'clear-queued-poll')
782
+ const queueFile = path.join(baseDir, 'clear-queued-poll', 'queue.json')
783
+ const watcher = createWatcherStub()
784
+ const watchSpy = jest.spyOn(fs, 'watch').mockReturnValue(watcher)
785
+ const readFileSpy = jest.spyOn(fs.promises, 'readFile')
786
+ const consumer = createQueue<{ value: number }>('clear-queued-poll', 'local', { baseDir })
787
+
788
+ try {
789
+ await consumer.enqueue({ value: 1 }, { delayMs: 10_000 })
790
+ await consumer.process(() => {})
791
+ await consumer.clear()
792
+ readFileSpy.mockClear()
793
+
794
+ await jest.advanceTimersByTimeAsync(1000)
795
+ jest.useRealTimers()
796
+ await new Promise((resolve) => setTimeout(resolve, 50))
797
+
798
+ const queueReads = readFileSpy.mock.calls.filter(([filePath]) => String(filePath) === queueFile)
799
+ expect(queueReads).toHaveLength(0)
800
+ } finally {
801
+ jest.useRealTimers()
802
+ await consumer.close()
803
+ readFileSpy.mockRestore()
804
+ watchSpy.mockRestore()
805
+ }
806
+ })
807
+
808
+ test('scoped removal cancels queued-work polling after draining the queue', async () => {
809
+ jest.useFakeTimers()
810
+ const baseDir = path.join(tmp, 'scoped-remove-queued-poll')
811
+ const queueFile = path.join(baseDir, 'scoped-remove-queued-poll', 'queue.json')
812
+ const watcher = createWatcherStub()
813
+ const watchSpy = jest.spyOn(fs, 'watch').mockReturnValue(watcher)
814
+ const readFileSpy = jest.spyOn(fs.promises, 'readFile')
815
+ const consumer = createQueue<{ tenantId: string; value: number }>('scoped-remove-queued-poll', 'local', { baseDir })
816
+
817
+ try {
818
+ await consumer.enqueue({ tenantId: 'tenant-1', value: 1 }, { delayMs: 10_000 })
819
+ await consumer.process(() => {})
820
+ await consumer.removeQueuedJobsByScope!({ tenantId: 'tenant-1' })
821
+ readFileSpy.mockClear()
822
+
823
+ await jest.advanceTimersByTimeAsync(1000)
824
+ jest.useRealTimers()
825
+ await new Promise((resolve) => setTimeout(resolve, 50))
826
+
827
+ const queueReads = readFileSpy.mock.calls.filter(([filePath]) => String(filePath) === queueFile)
828
+ expect(queueReads).toHaveLength(0)
829
+ } finally {
830
+ jest.useRealTimers()
831
+ await consumer.close()
832
+ readFileSpy.mockRestore()
833
+ watchSpy.mockRestore()
834
+ }
835
+ })
836
+
837
+ test('continuous processing survives a transient watcher stat failure', async () => {
838
+ const baseDir = path.join(tmp, 'watcher-stat-failure')
839
+ const consumer = createQueue<{ value: number }>('watcher-stat-failure', 'local', { baseDir })
840
+ const processed: number[] = []
841
+
842
+ await consumer.enqueue({ value: 1 })
843
+ const statError = Object.assign(new Error('Queue file temporarily unavailable'), { code: 'ENOENT' })
844
+ const statSpy = jest.spyOn(fs.promises, 'stat').mockRejectedValueOnce(statError)
845
+
846
+ try {
847
+ await expect(consumer.process((job) => {
848
+ processed.push(job.payload.value)
849
+ })).resolves.toEqual({ processed: -1, failed: -1, lastJobId: undefined })
850
+ expect(processed).toEqual([1])
851
+ } finally {
852
+ await consumer.close()
853
+ statSpy.mockRestore()
854
+ }
855
+ })
856
+
857
+ test('continuous processing falls back to polling when watcher setup fails', async () => {
858
+ const baseDir = path.join(tmp, 'watcher-setup-failure')
859
+ const consumer = createQueue<{ value: number }>('watcher-setup-failure', 'local', { baseDir })
860
+ const processed: number[] = []
861
+ const watchSpy = jest.spyOn(fs, 'watch').mockImplementation(() => {
862
+ throw new Error('Filesystem watching unavailable')
863
+ })
864
+
865
+ try {
866
+ await consumer.enqueue({ value: 1 })
867
+ await expect(consumer.process((job) => {
868
+ processed.push(job.payload.value)
869
+ })).resolves.toEqual({ processed: -1, failed: -1, lastJobId: undefined })
870
+ expect(processed).toEqual([1])
871
+ } finally {
872
+ await consumer.close()
873
+ watchSpy.mockRestore()
874
+ }
875
+ })
876
+
877
+ test('restarting continuous processing closes the previous watcher', async () => {
878
+ jest.useFakeTimers()
879
+ const firstWatcher = createWatcherStub()
880
+ const secondWatcher = createWatcherStub()
881
+ const watchSpy = jest.spyOn(fs, 'watch')
882
+ .mockReturnValueOnce(firstWatcher)
883
+ .mockReturnValueOnce(secondWatcher)
884
+ const consumer = createQueue<{ value: number }>('restart-worker', 'local', {
885
+ baseDir: path.join(tmp, 'restart-worker'),
886
+ })
887
+
888
+ try {
889
+ await consumer.process(() => {})
890
+ await consumer.process(() => {})
891
+
892
+ expect(firstWatcher.close).toHaveBeenCalledTimes(1)
893
+ } finally {
894
+ jest.useRealTimers()
895
+ await consumer.close()
896
+ firstWatcher.close()
897
+ secondWatcher.close()
898
+ watchSpy.mockRestore()
899
+ }
900
+ })
901
+
902
+ test('continuous processing rejects when its initial queue read fails', async () => {
903
+ const baseDir = path.join(tmp, 'initial-read-failure')
904
+ const queueFile = path.join(baseDir, 'initial-read-failure', 'queue.json')
905
+ const consumer = createQueue<{ value: number }>('initial-read-failure', 'local', { baseDir })
906
+ const actualReadFile = fs.promises.readFile
907
+
908
+ await consumer.enqueue({ value: 1 })
909
+ const readFileSpy = jest.spyOn(fs.promises, 'readFile').mockImplementation(async (filePath, ...args) => {
910
+ if (String(filePath) === queueFile) {
911
+ throw Object.assign(new Error('Permission denied'), { code: 'EACCES' })
912
+ }
913
+ return actualReadFile(filePath, ...args)
914
+ })
915
+ queueLoggerError.mockClear()
916
+
917
+ try {
918
+ await expect(consumer.process(() => {})).rejects.toThrow('Queue file unreadable')
919
+ expect(queueLoggerError).not.toHaveBeenCalledWith('Polling error', expect.anything())
920
+ } finally {
921
+ await consumer.close()
922
+ readFileSpy.mockRestore()
923
+ }
924
+ })
925
+
926
+ test('continuous workers keep polling while delayed jobs remain queued', async () => {
927
+ const baseDir = path.join(tmp, 'delayed-queue')
928
+ const producer = createQueue<{ value: number }>('delayed-queue', 'local', { baseDir })
929
+ const consumer = createQueue<{ value: number }>('delayed-queue', 'local', { baseDir })
930
+ let resolveProcessed!: (value: number) => void
931
+ const processed = new Promise<number>((resolve) => {
932
+ resolveProcessed = resolve
933
+ })
934
+
935
+ try {
936
+ await producer.enqueue({ value: 9 }, { delayMs: 250 })
937
+ await consumer.process((job) => {
938
+ resolveProcessed(job.payload.value)
939
+ })
940
+
941
+ await expect(within(processed, 1800)).resolves.toBe(9)
942
+ } finally {
943
+ await consumer.close()
944
+ await producer.close()
945
+ }
946
+ })
947
+
948
+ test('continuous workers retain wake-ups received during an active batch', async () => {
949
+ const baseDir = path.join(tmp, 'active-batch')
950
+ const producer = createQueue<{ value: number }>('active-batch', 'local', {
951
+ baseDir,
952
+ pollInterval: 5000,
953
+ })
954
+ const consumer = createQueue<{ value: number }>('active-batch', 'local', {
955
+ baseDir,
956
+ pollInterval: 5000,
957
+ })
958
+ let resolveFirstStarted!: () => void
959
+ const firstStarted = new Promise<void>((resolve) => {
960
+ resolveFirstStarted = resolve
961
+ })
962
+ let releaseFirst!: () => void
963
+ const firstRelease = new Promise<void>((resolve) => {
964
+ releaseFirst = resolve
965
+ })
966
+ let resolveSecondProcessed!: (value: number) => void
967
+ const secondProcessed = new Promise<number>((resolve) => {
968
+ resolveSecondProcessed = resolve
969
+ })
970
+
971
+ try {
972
+ await consumer.process(async (job) => {
973
+ if (job.payload.value === 1) {
974
+ resolveFirstStarted()
975
+ await firstRelease
976
+ return
977
+ }
978
+ resolveSecondProcessed(job.payload.value)
979
+ })
980
+
981
+ await producer.enqueue({ value: 1 })
982
+ await within(firstStarted, 800)
983
+ await producer.enqueue({ value: 2 })
984
+ releaseFirst()
985
+
986
+ await expect(within(secondProcessed, 800)).resolves.toBe(2)
987
+ } finally {
988
+ releaseFirst()
989
+ await consumer.close()
990
+ await producer.close()
991
+ }
992
+ })
993
+
625
994
  })
@@ -18,6 +18,11 @@ type StoredJob<T> = QueuedJob<T> & {
18
18
  attemptCount?: number
19
19
  }
20
20
 
21
+ type QueueFileIdentity = {
22
+ device: number
23
+ inode: number
24
+ }
25
+
21
26
  function payloadMatchesScope(payload: unknown, scope: QueueJobScope): boolean {
22
27
  if (!payload || typeof payload !== 'object') return false
23
28
  const scopedPayload = payload as { tenantId?: unknown; organizationId?: unknown; jobType?: unknown }
@@ -31,8 +36,10 @@ function payloadMatchesScope(payload: unknown, scope: QueueJobScope): boolean {
31
36
  return true
32
37
  }
33
38
 
34
- /** Default polling interval in milliseconds */
39
+ /** Polling interval while delayed or retrying work remains queued. */
35
40
  const DEFAULT_POLL_INTERVAL = 1000
41
+ /** Idle safety interval for missed filesystem watcher events. */
42
+ const DEFAULT_FALLBACK_POLL_INTERVAL = 5000
36
43
  const DEFAULT_LOCAL_QUEUE_BASE_DIR = '.mercato/queue'
37
44
  const DEFAULT_MAX_ATTEMPTS = 3
38
45
  const RETRY_BACKOFF_BASE_MS = 1000
@@ -120,10 +127,17 @@ export function createLocalQueue<T = unknown>(
120
127
  // Note: concurrency is stored for logging/compatibility but jobs are processed sequentially
121
128
  const concurrency = options?.concurrency ?? 1
122
129
  const pollInterval = options?.pollInterval ?? DEFAULT_POLL_INTERVAL
130
+ const fallbackPollInterval = Math.max(pollInterval, DEFAULT_FALLBACK_POLL_INTERVAL)
123
131
 
124
132
  // Worker state for continuous polling
125
133
  let pollingTimer: ReturnType<typeof setInterval> | null = null
134
+ let queuedPollTimer: ReturnType<typeof setTimeout> | null = null
135
+ let queueWatcher: fs.FSWatcher | null = null
136
+ let queueWatcherIdentity: QueueFileIdentity | null = null
137
+ let watcherRefreshChain: Promise<void> = Promise.resolve()
138
+ let hasQueuedJobs = false
126
139
  let isProcessing = false
140
+ let pollRequested = false
127
141
  let activeHandler: JobHandler<T> | null = null
128
142
  const inFlightJobIds = new Set<string>()
129
143
 
@@ -439,6 +453,7 @@ export function createLocalQueue<T = unknown>(
439
453
  const jobsRead = await readQueue()
440
454
  return { state: stateRead, jobs: jobsRead }
441
455
  })
456
+ hasQueuedJobs = jobs.length > 0
442
457
 
443
458
  const pendingJobs = jobs.filter((job) => {
444
459
  if (!job.availableAt) return true
@@ -503,6 +518,7 @@ export function createLocalQueue<T = unknown>(
503
518
  .filter((j) => !completedJobIds.has(j.id) && !deadJobIds.has(j.id))
504
519
  .map((j) => retryUpdates.get(j.id) ?? j)
505
520
  await writeQueue(updatedJobs)
521
+ hasQueuedJobs = updatedJobs.length > 0
506
522
 
507
523
  const newState: LocalState = {
508
524
  lastProcessedId: lastJobId,
@@ -524,18 +540,94 @@ export function createLocalQueue<T = unknown>(
524
540
  /**
525
541
  * Poll for and process new jobs.
526
542
  */
527
- async function pollAndProcess(): Promise<void> {
528
- // Skip if already processing to avoid concurrent file access
529
- if (isProcessing || !activeHandler) return
543
+ async function pollAndProcess(rethrow = false): Promise<void> {
544
+ if (!activeHandler) return
545
+ if (isProcessing) {
546
+ pollRequested = true
547
+ return
548
+ }
530
549
 
531
550
  isProcessing = true
532
551
  try {
533
- await processBatch(activeHandler)
552
+ do {
553
+ pollRequested = false
554
+ const handler = activeHandler
555
+ if (!handler) break
556
+ await processBatch(handler)
557
+ } while (pollRequested)
534
558
  } catch (error) {
559
+ if (rethrow) throw error
535
560
  logger.error('Polling error', { err: error })
536
561
  } finally {
537
562
  isProcessing = false
563
+ scheduleQueuedPoll()
564
+ }
565
+ }
566
+
567
+ function scheduleQueuedPoll(): void {
568
+ if (!activeHandler || !hasQueuedJobs) {
569
+ if (queuedPollTimer) {
570
+ clearTimeout(queuedPollTimer)
571
+ queuedPollTimer = null
572
+ }
573
+ return
538
574
  }
575
+ if (queuedPollTimer) return
576
+ queuedPollTimer = setTimeout(() => {
577
+ queuedPollTimer = null
578
+ void pollAndProcess()
579
+ }, pollInterval)
580
+ }
581
+
582
+ function closeQueueWatcher(): void {
583
+ if (queueWatcher) {
584
+ queueWatcher.close()
585
+ queueWatcher = null
586
+ }
587
+ queueWatcherIdentity = null
588
+ }
589
+
590
+ function refreshQueueWatcher(): Promise<void> {
591
+ const refresh = watcherRefreshChain.then(async () => {
592
+ if (!activeHandler) return
593
+ try {
594
+ await ensureDir()
595
+ const stats = await fsp.stat(queueFile)
596
+ const nextIdentity = { device: stats.dev, inode: stats.ino }
597
+ if (
598
+ queueWatcher
599
+ && queueWatcherIdentity?.device === nextIdentity.device
600
+ && queueWatcherIdentity.inode === nextIdentity.inode
601
+ ) {
602
+ return
603
+ }
604
+
605
+ closeQueueWatcher()
606
+ const watcher = fs.watch(queueFile, (eventType) => {
607
+ if (eventType === 'rename') {
608
+ queueWatcherIdentity = null
609
+ void refreshQueueWatcher()
610
+ }
611
+ void pollAndProcess()
612
+ })
613
+ watcher.on('error', (err) => {
614
+ logger.error('Queue watch error; fallback polling remains active', { err })
615
+ if (queueWatcher === watcher) {
616
+ closeQueueWatcher()
617
+ }
618
+ })
619
+ if (!activeHandler) {
620
+ watcher.close()
621
+ return
622
+ }
623
+ queueWatcher = watcher
624
+ queueWatcherIdentity = nextIdentity
625
+ } catch (err) {
626
+ logger.error('Failed to watch queue file; fallback polling remains active', { err })
627
+ }
628
+ })
629
+ watcherRefreshChain = refresh.catch(() => undefined)
630
+ return refresh
539
631
  }
540
632
 
541
633
  async function process(
@@ -547,18 +639,26 @@ export function createLocalQueue<T = unknown>(
547
639
  return processBatch(handler, options)
548
640
  }
549
641
 
642
+ if (activeHandler) {
643
+ await close()
644
+ }
645
+
550
646
  // Start continuous polling mode (like BullMQ Worker)
551
647
  activeHandler = handler
552
648
 
553
- // Process any pending jobs immediately
554
- await processBatch(handler)
649
+ try {
650
+ await refreshQueueWatcher()
651
+ await pollAndProcess(true)
652
+ } catch (error) {
653
+ await close()
654
+ throw error
655
+ }
555
656
 
556
- // Start polling interval for new jobs
557
657
  pollingTimer = setInterval(() => {
558
- pollAndProcess().catch((err) => {
658
+ refreshQueueWatcher().then(() => pollAndProcess()).catch((err) => {
559
659
  logger.error('Poll cycle error', { err })
560
660
  })
561
- }, pollInterval)
661
+ }, fallbackPollInterval)
562
662
 
563
663
  logger.info('Worker started', { concurrency })
564
664
 
@@ -571,6 +671,8 @@ export function createLocalQueue<T = unknown>(
571
671
  const jobs = await readQueue()
572
672
  const removed = jobs.length
573
673
  await writeQueue([])
674
+ hasQueuedJobs = false
675
+ scheduleQueuedPoll()
574
676
  // Reset state but preserve counts for historical tracking
575
677
  const state = await readState()
576
678
  await writeState({
@@ -589,18 +691,25 @@ export function createLocalQueue<T = unknown>(
589
691
  if (removed > 0) {
590
692
  await writeQueue(retainedJobs)
591
693
  }
694
+ hasQueuedJobs = retainedJobs.length > 0
695
+ scheduleQueuedPoll()
592
696
  return { removed }
593
697
  })
594
698
  }
595
699
 
596
700
  async function close(): Promise<void> {
701
+ activeHandler = null
702
+ if (queuedPollTimer) {
703
+ clearTimeout(queuedPollTimer)
704
+ queuedPollTimer = null
705
+ }
706
+ closeQueueWatcher()
707
+
597
708
  // Stop polling timer
598
709
  if (pollingTimer) {
599
710
  clearInterval(pollingTimer)
600
711
  pollingTimer = null
601
712
  }
602
- activeHandler = null
603
-
604
713
  // Wait for any in-progress processing to complete (with timeout)
605
714
  const SHUTDOWN_TIMEOUT = 5000
606
715
  const startTime = Date.now()
package/src/types.ts CHANGED
@@ -59,7 +59,7 @@ export type LocalQueueOptions = {
59
59
  baseDir?: string
60
60
  /** Number of concurrent job processors. Defaults to 1 */
61
61
  concurrency?: number
62
- /** Polling interval in milliseconds for continuous processing. Defaults to 1000 */
62
+ /** Polling interval in milliseconds while work is queued. Idle safety polling uses at least 5000 ms. Defaults to 1000. */
63
63
  pollInterval?: number
64
64
  }
65
65