-
Notifications
You must be signed in to change notification settings - Fork 0
Add Cron expression support for job scheduling #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| import { randomUUID } from 'crypto'; | ||
| import { promises as fs } from 'fs'; | ||
| import { open } from 'fs/promises'; | ||
| import path from 'path'; | ||
| import type { DistributedMutex, MutexLockOptions } from '../interfaces/mutex.ts'; | ||
| import { MutexAcquireError } from '../interfaces/mutex.ts'; | ||
|
|
||
| interface LockFileContent { | ||
| token: string; | ||
| expiresAt: number; | ||
| pid: number; | ||
| } | ||
|
|
||
| /** | ||
| * File-based distributed mutex implementation. | ||
| * | ||
| * Uses filesystem-level locking with exclusive file creation (O_EXCL flag) | ||
| * to ensure atomic lock acquisition. Lock files contain a token and expiration | ||
| * time to handle stale locks from crashed processes. | ||
| * | ||
| * This implementation is suitable for distributed locking when multiple processes | ||
| * have access to the same filesystem (e.g., same machine or shared network filesystem). | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * import { FileMutex } from 'adapter-queue/file-mutex'; | ||
| * | ||
| * const mutex = new FileMutex({ lockDir: '/tmp/myapp/locks' }); | ||
| * | ||
| * await mutex.withLock('critical-section', async () => { | ||
| * // Only one process can execute this at a time | ||
| * await performCriticalOperation(); | ||
| * }); | ||
| * ``` | ||
| */ | ||
| export class FileMutex implements DistributedMutex { | ||
| private lockDir: string; | ||
| private initialized = false; | ||
|
|
||
| /** | ||
| * Create a new file-based mutex. | ||
| * | ||
| * @param options - Configuration options | ||
| * @param options.lockDir - Directory to store lock files (default: '/tmp/mutex-locks') | ||
| */ | ||
| constructor(options?: { lockDir?: string }) { | ||
| this.lockDir = options?.lockDir ?? '/tmp/mutex-locks'; | ||
| } | ||
|
|
||
| private async ensureInitialized(): Promise<void> { | ||
| if (this.initialized) return; | ||
|
|
||
| await fs.mkdir(this.lockDir, { recursive: true, mode: 0o755 }); | ||
| this.initialized = true; | ||
| } | ||
|
|
||
| private getLockPath(key: string): string { | ||
| // Sanitize key to be filesystem-safe | ||
| const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, '_'); | ||
| return path.join(this.lockDir, `${safeKey}.lock`); | ||
| } | ||
|
|
||
| async tryAcquire(key: string, ttlMs: number): Promise<string | null> { | ||
| await this.ensureInitialized(); | ||
|
|
||
| const lockPath = this.getLockPath(key); | ||
| const token = randomUUID(); | ||
| const expiresAt = Date.now() + ttlMs; | ||
|
|
||
| // First, check if an existing lock has expired | ||
| try { | ||
| const existingContent = await fs.readFile(lockPath, 'utf-8'); | ||
| const existingLock: LockFileContent = JSON.parse(existingContent); | ||
|
|
||
| if (existingLock.expiresAt < Date.now()) { | ||
| // Lock is expired, try to remove it | ||
| try { | ||
| await fs.unlink(lockPath); | ||
| } catch { | ||
| // Another process may have removed it, continue trying to acquire | ||
| } | ||
| } else { | ||
| // Lock is still valid | ||
| return null; | ||
| } | ||
| } catch (err: any) { | ||
| if (err.code !== 'ENOENT') { | ||
| // Unexpected error reading lock file | ||
| throw err; | ||
| } | ||
| // Lock file doesn't exist, continue to acquire | ||
| } | ||
|
|
||
| // Try to create lock file exclusively | ||
| let handle; | ||
| try { | ||
| handle = await open(lockPath, 'wx'); | ||
|
|
||
| const lockContent: LockFileContent = { | ||
| token, | ||
| expiresAt, | ||
| pid: process.pid, | ||
| }; | ||
|
|
||
| await handle.writeFile(JSON.stringify(lockContent), 'utf-8'); | ||
| await handle.close(); | ||
|
|
||
| return token; | ||
| } catch (err: any) { | ||
| if (handle) { | ||
| try { | ||
| await handle.close(); | ||
| } catch { | ||
| // Ignore close errors | ||
| } | ||
| } | ||
|
|
||
| if (err.code === 'EEXIST') { | ||
| // Lock file was created by another process | ||
| return null; | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| async release(key: string, token: string): Promise<boolean> { | ||
| await this.ensureInitialized(); | ||
|
|
||
| const lockPath = this.getLockPath(key); | ||
|
|
||
| try { | ||
| const content = await fs.readFile(lockPath, 'utf-8'); | ||
| const lock: LockFileContent = JSON.parse(content); | ||
|
|
||
| // Only delete if token matches (we own the lock) | ||
| if (lock.token === token) { | ||
| await fs.unlink(lockPath); | ||
| return true; | ||
| } | ||
|
|
||
| return false; | ||
| } catch (err: any) { | ||
| if (err.code === 'ENOENT') { | ||
| // Lock file doesn't exist (may have expired and been cleaned up) | ||
| return false; | ||
| } | ||
| throw err; | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: File mutex release has TOCTOU race conditionThe |
||
| } | ||
|
|
||
| async withLock<T>( | ||
| key: string, | ||
| fn: () => Promise<T>, | ||
| options?: MutexLockOptions | ||
| ): Promise<T> { | ||
| const { ttlMs = 30000, waitMs = 5000, retryIntervalMs = 100 } = options ?? {}; | ||
| const deadline = Date.now() + waitMs; | ||
| let token: string | null = null; | ||
|
|
||
| // Try to acquire lock with retries | ||
| while (Date.now() < deadline) { | ||
| token = await this.tryAcquire(key, ttlMs); | ||
| if (token) break; | ||
| await sleep(retryIntervalMs); | ||
| } | ||
|
|
||
| if (!token) { | ||
| throw new MutexAcquireError(key, waitMs); | ||
| } | ||
|
|
||
| try { | ||
| return await fn(); | ||
| } finally { | ||
| await this.release(key, token); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Clean up all expired locks in the lock directory. | ||
| * This can be called periodically for maintenance. | ||
| * | ||
| * @returns Number of expired locks cleaned up | ||
| */ | ||
| async cleanup(): Promise<number> { | ||
| await this.ensureInitialized(); | ||
|
|
||
| let cleaned = 0; | ||
| const now = Date.now(); | ||
|
|
||
| try { | ||
| const files = await fs.readdir(this.lockDir); | ||
|
|
||
| for (const file of files) { | ||
| if (!file.endsWith('.lock')) continue; | ||
|
|
||
| const lockPath = path.join(this.lockDir, file); | ||
| try { | ||
| const content = await fs.readFile(lockPath, 'utf-8'); | ||
| const lock: LockFileContent = JSON.parse(content); | ||
|
|
||
| if (lock.expiresAt < now) { | ||
| await fs.unlink(lockPath); | ||
| cleaned++; | ||
| } | ||
| } catch { | ||
| // Ignore errors for individual files | ||
| } | ||
| } | ||
| } catch { | ||
| // Ignore errors listing directory | ||
| } | ||
|
|
||
| return cleaned; | ||
| } | ||
| } | ||
|
|
||
| function sleep(ms: number): Promise<void> { | ||
| return new Promise(resolve => setTimeout(resolve, ms)); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| import { randomUUID } from 'crypto'; | ||
| import type { DistributedMutex, MutexLockOptions } from '../interfaces/mutex.ts'; | ||
| import { MutexAcquireError } from '../interfaces/mutex.ts'; | ||
|
|
||
| interface LockEntry { | ||
| token: string; | ||
| expiresAt: number; | ||
| timeout: NodeJS.Timeout; | ||
| } | ||
|
|
||
| /** | ||
| * In-memory mutex implementation for testing and single-process applications. | ||
| * | ||
| * This mutex only works within a single process and is not suitable for | ||
| * distributed locking across multiple processes or servers. Use it for: | ||
| * - Unit testing | ||
| * - Development environments | ||
| * - Single-process applications that need mutex semantics | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * import { InMemoryMutex } from 'adapter-queue/memory-mutex'; | ||
| * | ||
| * const mutex = new InMemoryMutex(); | ||
| * | ||
| * await mutex.withLock('critical-section', async () => { | ||
| * // Only one async operation can execute this at a time | ||
| * await performCriticalOperation(); | ||
| * }); | ||
| * ``` | ||
| */ | ||
| export class InMemoryMutex implements DistributedMutex { | ||
| private locks = new Map<string, LockEntry>(); | ||
|
|
||
| async tryAcquire(key: string, ttlMs: number): Promise<string | null> { | ||
| const existing = this.locks.get(key); | ||
|
|
||
| // Check if lock exists and is not expired | ||
| if (existing && existing.expiresAt > Date.now()) { | ||
| return null; | ||
| } | ||
|
|
||
| // Clean up expired lock if it exists | ||
| if (existing) { | ||
| clearTimeout(existing.timeout); | ||
| this.locks.delete(key); | ||
| } | ||
|
|
||
| const token = randomUUID(); | ||
| const expiresAt = Date.now() + ttlMs; | ||
|
|
||
| // Set up automatic cleanup on expiration | ||
| const timeout = setTimeout(() => { | ||
| const lock = this.locks.get(key); | ||
| if (lock && lock.token === token) { | ||
| this.locks.delete(key); | ||
| } | ||
| }, ttlMs); | ||
|
|
||
| this.locks.set(key, { token, expiresAt, timeout }); | ||
|
|
||
| return token; | ||
| } | ||
|
|
||
| async release(key: string, token: string): Promise<boolean> { | ||
| const lock = this.locks.get(key); | ||
|
|
||
| if (!lock || lock.token !== token) { | ||
| return false; | ||
| } | ||
|
|
||
| clearTimeout(lock.timeout); | ||
| this.locks.delete(key); | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| async withLock<T>( | ||
| key: string, | ||
| fn: () => Promise<T>, | ||
| options?: MutexLockOptions | ||
| ): Promise<T> { | ||
| const { ttlMs = 30000, waitMs = 5000, retryIntervalMs = 100 } = options ?? {}; | ||
| const deadline = Date.now() + waitMs; | ||
| let token: string | null = null; | ||
|
|
||
| // Try to acquire lock with retries | ||
| while (Date.now() < deadline) { | ||
| token = await this.tryAcquire(key, ttlMs); | ||
| if (token) break; | ||
| await sleep(retryIntervalMs); | ||
| } | ||
|
|
||
| if (!token) { | ||
| throw new MutexAcquireError(key, waitMs); | ||
| } | ||
|
|
||
| try { | ||
| return await fn(); | ||
| } finally { | ||
| await this.release(key, token); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Check if a lock is currently held. | ||
| * Useful for testing and debugging. | ||
| * | ||
| * @param key - The lock key to check | ||
| * @returns true if the lock is held and not expired | ||
| */ | ||
| isLocked(key: string): boolean { | ||
| const lock = this.locks.get(key); | ||
| return !!lock && lock.expiresAt > Date.now(); | ||
| } | ||
|
|
||
| /** | ||
| * Get the number of active locks. | ||
| * Useful for testing and debugging. | ||
| */ | ||
| get size(): number { | ||
| return this.locks.size; | ||
| } | ||
|
|
||
| /** | ||
| * Clear all locks. Useful for test cleanup. | ||
| */ | ||
| clear(): void { | ||
| for (const lock of this.locks.values()) { | ||
| clearTimeout(lock.timeout); | ||
| } | ||
| this.locks.clear(); | ||
| } | ||
| } | ||
|
|
||
| function sleep(ms: number): Promise<void> { | ||
| return new Promise(resolve => setTimeout(resolve, ms)); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Key sanitization causes different keys to collide
The
getLockPathmethod sanitizes keys by replacing all non-alphanumeric characters (except_and-) with underscores. This causes different logical keys to map to the same lock file, breaking mutex semantics. For example,"key:1","key.1","key/1", and"key_1"all become"key_1.lock". A process acquiring a lock on"resource:abc"would incorrectly block another process trying to lock"resource.abc", even though these are intended to be independent locks.