@open-mercato/queue 0.6.8-develop.6948.1.8369fc4c97 → 0.6.8-develop.6950.1.912d574d60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/strategies/local.js +104 -7
- package/dist/strategies/local.js.map +2 -2
- package/package.json +3 -3
- package/src/__tests__/local.strategy.test.ts +369 -0
- package/src/strategies/local.ts +121 -12
- package/src/types.ts +1 -1
package/dist/strategies/local.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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": "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",
|
|
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.
|
|
3
|
+
"version": "0.6.8-develop.6950.1.912d574d60",
|
|
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.
|
|
60
|
-
"@open-mercato/telemetry": "0.6.8-develop.
|
|
59
|
+
"@open-mercato/shared": "0.6.8-develop.6950.1.912d574d60",
|
|
60
|
+
"@open-mercato/telemetry": "0.6.8-develop.6950.1.912d574d60"
|
|
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
|
})
|
package/src/strategies/local.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
529
|
-
if (isProcessing
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
},
|
|
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
|
|
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
|
|