@shagital/atomic-lock
Version:
Universal atomic locking with pluggable drivers (Redis, SQLite, File, Memory)
239 lines (238 loc) • 8.2 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.FileLockDriver = void 0;
const fs = __importStar(require("fs/promises"));
const path = __importStar(require("path"));
const crypto = __importStar(require("crypto"));
/**
* File system driver implementation
* Uses atomic file operations and directory-based locking
*/
class FileLockDriver {
constructor(config) {
this.lockDir = config.lockDir;
this.ensureLockDir();
if (config.cleanupInterval) {
this.cleanupInterval = setInterval(() => {
this.cleanup().catch(console.error);
}, config.cleanupInterval);
}
}
async ensureLockDir() {
try {
await fs.mkdir(this.lockDir, { recursive: true });
}
catch (error) {
// Directory might already exist
}
}
getLockPath(key) {
// Sanitize key for filesystem
const sanitized = key.replace(/[^a-zA-Z0-9-_]/g, '_');
// If the sanitized key is too long, hash it
if (sanitized.length > FileLockDriver.MAX_FILENAME_LENGTH) {
const hash = crypto.createHash('sha256').update(key).digest('hex');
// Include a prefix of the original key for debugging, then the hash
const prefix = sanitized.substring(0, 20);
const filename = `${prefix}_${hash}`;
return path.join(this.lockDir, `${filename}.lock`);
}
return path.join(this.lockDir, `${sanitized}.lock`);
}
async tryAcquire(key, lockValue, expiryInSeconds) {
const lockPath = this.getLockPath(key);
const expiresAt = Date.now() + (expiryInSeconds * 1000);
const lockData = {
value: lockValue,
expiresAt,
createdAt: Date.now()
};
try {
// Use 'wx' flag for exclusive creation (atomic)
await fs.writeFile(lockPath, JSON.stringify(lockData), { flag: 'wx' });
return true;
}
catch (error) {
if (error.code === 'EEXIST') {
// Lock file exists, check if expired
try {
const existing = await this.readLockFile(lockPath);
if (existing && existing.expiresAt < Date.now()) {
// Expired lock, try to remove and acquire
await fs.unlink(lockPath);
return this.tryAcquire(key, lockValue, expiryInSeconds);
}
}
catch {
// If we can't read the lock file, assume it's valid
}
return false;
}
throw error;
}
}
async tryAcquireMultiple(keys, lockValue, expiryInSeconds) {
const lockPaths = keys.map(key => this.getLockPath(key));
const expiresAt = Date.now() + (expiryInSeconds * 1000);
const lockData = {
value: lockValue,
expiresAt,
createdAt: Date.now()
};
// Check if any locks exist
for (const lockPath of lockPaths) {
try {
const existing = await this.readLockFile(lockPath);
if (existing && existing.expiresAt >= Date.now()) {
return false; // Lock is active
}
}
catch {
// File doesn't exist, which is what we want
}
}
// Try to acquire all locks
const acquired = [];
try {
for (const lockPath of lockPaths) {
await fs.writeFile(lockPath, JSON.stringify(lockData), { flag: 'wx' });
acquired.push(lockPath);
}
return true;
}
catch (error) {
// Rollback any acquired locks
for (const acquiredPath of acquired) {
try {
await fs.unlink(acquiredPath);
}
catch {
// Best effort cleanup
}
}
return false;
}
}
async release(key, lockValue) {
const lockPath = this.getLockPath(key);
try {
const existing = await this.readLockFile(lockPath);
if (existing && existing.value === lockValue) {
await fs.unlink(lockPath);
return true;
}
return false;
}
catch {
return false;
}
}
async releaseMultiple(keys, lockValue) {
let released = 0;
for (const key of keys) {
const success = await this.release(key, lockValue);
if (success)
released++;
}
return released;
}
async exists(key) {
const lockPath = this.getLockPath(key);
try {
const existing = await this.readLockFile(lockPath);
return existing !== null && existing.expiresAt >= Date.now();
}
catch {
return false;
}
}
async getLockInfo(key) {
const lockPath = this.getLockPath(key);
try {
const fileLock = await this.readLockFile(lockPath);
if (!fileLock)
return null;
return {
key,
value: fileLock.value,
expiresAt: fileLock.expiresAt,
createdAt: fileLock.createdAt
};
}
catch {
return null;
}
}
async cleanup() {
try {
const files = await fs.readdir(this.lockDir);
const now = Date.now();
for (const file of files) {
if (file.endsWith('.lock')) {
const lockPath = path.join(this.lockDir, file);
try {
const lockData = await this.readLockFile(lockPath);
if (lockData && lockData.expiresAt < now) {
await fs.unlink(lockPath);
}
}
catch {
// Skip files we can't read/parse
}
}
}
}
catch (error) {
console.error('Error during lock cleanup:', error);
}
}
async close() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
}
async readLockFile(lockPath) {
try {
const content = await fs.readFile(lockPath, 'utf8');
return JSON.parse(content);
}
catch {
return null;
}
}
}
exports.FileLockDriver = FileLockDriver;
FileLockDriver.MAX_FILENAME_LENGTH = 200; // Safe limit for most filesystems