2025-09-22 15:45:35 -07:00
|
|
|
/**
|
|
|
|
|
* Universal Mutex Implementation for Thread-Safe Operations
|
|
|
|
|
* Provides consistent locking across all storage adapters
|
|
|
|
|
* Critical for preventing race conditions in count operations
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export interface MutexInterface {
|
|
|
|
|
acquire(key: string, timeout?: number): Promise<() => void>
|
|
|
|
|
runExclusive<T>(key: string, fn: () => Promise<T>, timeout?: number): Promise<T>
|
|
|
|
|
isLocked(key: string): boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* In-memory mutex for single-process scenarios
|
|
|
|
|
* Used by MemoryStorage and as fallback for other adapters
|
|
|
|
|
*/
|
|
|
|
|
export class InMemoryMutex implements MutexInterface {
|
|
|
|
|
private locks: Map<string, {
|
|
|
|
|
queue: Array<() => void>
|
|
|
|
|
locked: boolean
|
|
|
|
|
}> = new Map()
|
|
|
|
|
|
|
|
|
|
async acquire(key: string, timeout: number = 30000): Promise<() => void> {
|
|
|
|
|
if (!this.locks.has(key)) {
|
|
|
|
|
this.locks.set(key, { queue: [], locked: false })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lock = this.locks.get(key)!
|
|
|
|
|
|
|
|
|
|
if (!lock.locked) {
|
|
|
|
|
lock.locked = true
|
|
|
|
|
return () => this.release(key)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait in queue
|
|
|
|
|
return new Promise<() => void>((resolve, reject) => {
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
const index = lock.queue.indexOf(resolver)
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
lock.queue.splice(index, 1)
|
|
|
|
|
}
|
|
|
|
|
reject(new Error(`Mutex timeout for key: ${key}`))
|
|
|
|
|
}, timeout)
|
|
|
|
|
|
|
|
|
|
const resolver = () => {
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
lock.locked = true
|
|
|
|
|
resolve(() => this.release(key))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lock.queue.push(resolver)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private release(key: string): void {
|
|
|
|
|
const lock = this.locks.get(key)
|
|
|
|
|
if (!lock) return
|
|
|
|
|
|
|
|
|
|
if (lock.queue.length > 0) {
|
|
|
|
|
const next = lock.queue.shift()!
|
|
|
|
|
next()
|
|
|
|
|
} else {
|
|
|
|
|
lock.locked = false
|
|
|
|
|
// Clean up if no waiters
|
|
|
|
|
if (lock.queue.length === 0) {
|
|
|
|
|
this.locks.delete(key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async runExclusive<T>(
|
|
|
|
|
key: string,
|
|
|
|
|
fn: () => Promise<T>,
|
|
|
|
|
timeout?: number
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
const release = await this.acquire(key, timeout)
|
|
|
|
|
try {
|
|
|
|
|
return await fn()
|
|
|
|
|
} finally {
|
|
|
|
|
release()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isLocked(key: string): boolean {
|
|
|
|
|
return this.locks.get(key)?.locked || false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* File-based mutex for multi-process scenarios (Node.js)
|
|
|
|
|
* Uses atomic file operations to prevent TOCTOU races
|
|
|
|
|
*/
|
|
|
|
|
export class FileMutex implements MutexInterface {
|
|
|
|
|
private fs: any
|
|
|
|
|
private path: any
|
|
|
|
|
private lockDir: string
|
|
|
|
|
private processLocks: Map<string, () => void> = new Map()
|
|
|
|
|
private lockTimers: Map<string, NodeJS.Timeout> = new Map()
|
2025-09-25 10:47:44 -07:00
|
|
|
private modulesLoaded: boolean = false
|
2025-09-22 15:45:35 -07:00
|
|
|
|
|
|
|
|
constructor(lockDir: string) {
|
|
|
|
|
this.lockDir = lockDir
|
2025-09-25 10:47:44 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async loadNodeModules(): Promise<void> {
|
|
|
|
|
if (this.modulesLoaded) return
|
|
|
|
|
|
2025-09-22 15:45:35 -07:00
|
|
|
if (typeof window === 'undefined') {
|
2025-09-25 10:47:44 -07:00
|
|
|
// Modern ESM-compatible dynamic imports
|
|
|
|
|
const [fs, path] = await Promise.all([
|
|
|
|
|
import('fs'),
|
|
|
|
|
import('path')
|
|
|
|
|
])
|
|
|
|
|
this.fs = fs
|
|
|
|
|
this.path = path
|
|
|
|
|
this.modulesLoaded = true
|
2025-09-22 15:45:35 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async acquire(key: string, timeout: number = 30000): Promise<() => void> {
|
2025-09-25 10:47:44 -07:00
|
|
|
await this.loadNodeModules()
|
|
|
|
|
|
2025-09-22 15:45:35 -07:00
|
|
|
if (!this.fs || !this.path) {
|
|
|
|
|
throw new Error('FileMutex is only available in Node.js environments')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lockFile = this.path.join(this.lockDir, `${key}.lock`)
|
|
|
|
|
const lockId = `${Date.now()}_${Math.random()}_${process.pid}`
|
|
|
|
|
const startTime = Date.now()
|
|
|
|
|
|
|
|
|
|
// Ensure lock directory exists
|
|
|
|
|
await this.fs.promises.mkdir(this.lockDir, { recursive: true })
|
|
|
|
|
|
|
|
|
|
while (Date.now() - startTime < timeout) {
|
|
|
|
|
try {
|
|
|
|
|
// Atomic lock creation using 'wx' flag
|
|
|
|
|
await this.fs.promises.writeFile(
|
|
|
|
|
lockFile,
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
lockId,
|
|
|
|
|
pid: process.pid,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
expiresAt: Date.now() + timeout
|
|
|
|
|
}),
|
|
|
|
|
{ flag: 'wx' } // Write exclusive - fails if exists
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Successfully acquired lock
|
|
|
|
|
const release = () => this.release(key, lockFile, lockId)
|
|
|
|
|
this.processLocks.set(key, release)
|
|
|
|
|
|
|
|
|
|
// Auto-release on timeout
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
release()
|
|
|
|
|
}, timeout)
|
|
|
|
|
this.lockTimers.set(key, timer)
|
|
|
|
|
|
|
|
|
|
return release
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
if (error.code === 'EEXIST') {
|
|
|
|
|
// Lock exists - check if expired
|
|
|
|
|
try {
|
|
|
|
|
const data = await this.fs.promises.readFile(lockFile, 'utf-8')
|
|
|
|
|
const lock = JSON.parse(data)
|
|
|
|
|
|
|
|
|
|
if (lock.expiresAt < Date.now()) {
|
|
|
|
|
// Expired - try to remove
|
|
|
|
|
try {
|
|
|
|
|
await this.fs.promises.unlink(lockFile)
|
|
|
|
|
continue // Retry acquisition
|
|
|
|
|
} catch (unlinkError: any) {
|
|
|
|
|
if (unlinkError.code !== 'ENOENT') {
|
|
|
|
|
// Someone else removed it, continue
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Can't read lock file, assume it's valid
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait before retry
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
|
} else {
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(`Failed to acquire mutex for key: ${key} after ${timeout}ms`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async release(key: string, lockFile: string, lockId: string): Promise<void> {
|
|
|
|
|
// Clear timer
|
|
|
|
|
const timer = this.lockTimers.get(key)
|
|
|
|
|
if (timer) {
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
this.lockTimers.delete(key)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove from process locks
|
|
|
|
|
this.processLocks.delete(key)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Verify we own the lock before releasing
|
|
|
|
|
const data = await this.fs.promises.readFile(lockFile, 'utf-8')
|
|
|
|
|
const lock = JSON.parse(data)
|
|
|
|
|
|
|
|
|
|
if (lock.lockId === lockId) {
|
|
|
|
|
await this.fs.promises.unlink(lockFile)
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Lock already released or doesn't exist
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async runExclusive<T>(
|
|
|
|
|
key: string,
|
|
|
|
|
fn: () => Promise<T>,
|
|
|
|
|
timeout?: number
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
const release = await this.acquire(key, timeout)
|
|
|
|
|
try {
|
|
|
|
|
return await fn()
|
|
|
|
|
} finally {
|
|
|
|
|
release()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isLocked(key: string): boolean {
|
|
|
|
|
return this.processLocks.has(key)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clean up all locks held by this process
|
|
|
|
|
*/
|
|
|
|
|
async cleanup(): Promise<void> {
|
|
|
|
|
// Clear all timers
|
|
|
|
|
for (const timer of this.lockTimers.values()) {
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
}
|
|
|
|
|
this.lockTimers.clear()
|
|
|
|
|
|
|
|
|
|
// Release all locks
|
|
|
|
|
const releases = Array.from(this.processLocks.values())
|
|
|
|
|
await Promise.all(releases.map(release => release()))
|
|
|
|
|
this.processLocks.clear()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Factory to create appropriate mutex for the environment
|
|
|
|
|
*/
|
|
|
|
|
export function createMutex(options?: {
|
|
|
|
|
type?: 'memory' | 'file'
|
|
|
|
|
lockDir?: string
|
|
|
|
|
}): MutexInterface {
|
|
|
|
|
const type = options?.type || (typeof window === 'undefined' ? 'file' : 'memory')
|
|
|
|
|
|
|
|
|
|
if (type === 'file' && typeof window === 'undefined') {
|
|
|
|
|
const lockDir = options?.lockDir || '.brainy/locks'
|
|
|
|
|
return new FileMutex(lockDir)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new InMemoryMutex()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Global mutex instance for count operations
|
|
|
|
|
let globalMutex: MutexInterface | null = null
|
|
|
|
|
|
|
|
|
|
export function getGlobalMutex(): MutexInterface {
|
|
|
|
|
if (!globalMutex) {
|
|
|
|
|
globalMutex = createMutex()
|
|
|
|
|
}
|
|
|
|
|
return globalMutex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cleanup function for graceful shutdown
|
|
|
|
|
*/
|
|
|
|
|
export async function cleanupMutexes(): Promise<void> {
|
|
|
|
|
if (globalMutex && 'cleanup' in globalMutex) {
|
|
|
|
|
await (globalMutex as any).cleanup()
|
|
|
|
|
}
|
|
|
|
|
}
|