From d3637a21c5cea6a06be785cf7a86f088e86a375f Mon Sep 17 00:00:00 2001 From: kingecg Date: Thu, 13 Nov 2025 22:32:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(init):=20=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=BB=93=E6=9E=84=E5=92=8C=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加了异步锁库的基础实现,包括内存锁和文件锁功能。 - 新增 `AsyncLock` 类用于进程内异步加锁 - 新增 `FileLock` 类用于跨进程的文件锁机制 - 添加单元测试覆盖核心逻辑 - 配置 Jest 测试环境并启用覆盖率收集 - 创建 README 文档说明安装、使用方法与 API 详情 - 添加 .gitignore 忽略构建产物及敏感文件 - 添加 MIT 许可证声明 该提交涵盖了项目的初始设置以及基本功能的完整实现。 --- .gitignore | 17 +++ LICENSE | 21 ++++ README.md | 96 +++++++++++++++++ __tests__/async-lock.test.js | 137 ++++++++++++++++++++++++ __tests__/file-lock.test.js | 146 +++++++++++++++++++++++++ async-lock.js | 35 ++++++ file-lock.js | 109 +++++++++++++++++++ index.js | 9 ++ jest.config.js | 200 +++++++++++++++++++++++++++++++++++ package.json | 15 +++ 10 files changed, 785 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __tests__/async-lock.test.js create mode 100644 __tests__/file-lock.test.js create mode 100644 async-lock.js create mode 100644 file-lock.js create mode 100644 index.js create mode 100644 jest.config.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3dedf6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +node_modules +coverage +.nyc_output +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnp.* +yarn.lock +package-lock.json + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8cb38c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 nlocks contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..24b919a --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# nlocks + +A Node.js library providing asynchronous locking mechanisms including in-memory locks and file-based locks for coordinating access to resources across concurrent processes. + +## Features + +- **AsyncLock**: In-memory asynchronous lock for coordinating access to resources within a single process +- **FileLock**: File-based lock for coordinating access to resources across multiple processes +- Lightweight and easy to use +- Promise-based API +- Comprehensive test coverage + +## Installation + +```bash +npm install nlocks +``` + +## Usage + +### Via index.js (recommended) + +```javascript +const { AsyncLock, FileLock } = require('nlocks'); + +const asyncLock = new AsyncLock(); +const fileLock = new FileLock(); +``` + +### AsyncLock + +An in-memory lock for synchronizing async operations within a single Node.js process: + +```javascript +const AsyncLock = require('nlocks/async-lock'); + +const lock = new AsyncLock(); + +async function protectedOperation() { + await lock.acquire(); + try { + // Your critical section code here + console.log('Performing protected operation'); + await someAsyncWork(); + } finally { + lock.release(); + } +} +``` + +### FileLock + +A file-based lock for synchronizing operations across multiple Node.js processes: + +```javascript +const FileLock = require('nlocks/file-lock'); + +const lock = new FileLock(); + +async function fileProtectedOperation() { + await lock.acquire('/path/to/your/file.txt'); + try { + // Your file operation code here + console.log('Performing file operation'); + await someFileAsyncWork(); + } finally { + lock.release('/path/to/your/file.txt'); + } +} +``` + +## API + +### AsyncLock + +- `new AsyncLock()` - Creates a new AsyncLock instance +- `acquire(): Promise` - Acquires the lock +- `release(): void` - Releases the lock + +### FileLock + +- `new FileLock()` - Creates a new FileLock instance +- `acquire(filePath): Promise` - Acquires a lock for the specified file path +- `release(filePath): void` - Releases the lock for the specified file path + +## Testing + +Run the test suite with: + +```bash +npm test +``` + +## License + +[MIT](LICENSE) \ No newline at end of file diff --git a/__tests__/async-lock.test.js b/__tests__/async-lock.test.js new file mode 100644 index 0000000..ef51556 --- /dev/null +++ b/__tests__/async-lock.test.js @@ -0,0 +1,137 @@ +const AsyncLock = require('../async-lock'); + +describe('AsyncLock', () => { + let lock; + + beforeEach(() => { + lock = new AsyncLock(); + }); + + describe('acquire and release', () => { + test('should acquire lock without waiting when not locked', async () => { + const startTime = Date.now(); + await lock.acquire(); + const endTime = Date.now(); + + // Should resolve almost immediately (no waiting) + expect(endTime - startTime).toBeLessThan(10); + + lock.release(); + }); + + test('should protect async function without waiting when not locked', async () => { + let protectedFunctionCalled = false; + + const protectedFunction = async () => { + await lock.acquire(); + protectedFunctionCalled = true; + // Simulate some async work + await new Promise(resolve => setTimeout(resolve, 10)); + lock.release(); + return 'done'; + }; + + const result = await protectedFunction(); + + expect(protectedFunctionCalled).toBe(true); + expect(result).toBe('done'); + }); + + test('should allow consecutive acquisitions and releases without queueing', async () => { + // First acquisition + await lock.acquire(); + expect(lock.locked).toBe(true); + lock.release(); + + // Second acquisition + await lock.acquire(); + expect(lock.locked).toBe(true); + lock.release(); + + // Third acquisition + await lock.acquire(); + expect(lock.locked).toBe(true); + lock.release(); + }); + + test('should handle multiple concurrent acquisitions correctly', async () => { + let counter = 0; + + const incrementCounter = async () => { + await lock.acquire(); + const temp = counter; + // Simulate some async work that could cause race condition + await new Promise(resolve => setTimeout(resolve, 1)); + counter = temp + 1; + lock.release(); + }; + + // Run multiple concurrent operations + await Promise.all([ + incrementCounter(), + incrementCounter(), + incrementCounter() + ]); + + expect(counter).toBe(3); + }); + + test('should handle case where release is called without any pending acquirers', () => { + // Initially unlocked + expect(lock.locked).toBe(false); + + // Calling release on unlocked lock should not error + lock.release(); + expect(lock.locked).toBe(false); + }); + + test('should properly queue multiple concurrent requests', async () => { + let executionOrder = []; + + const task = async (id) => { + await lock.acquire(); + executionOrder.push(id); + // Simulate work + await new Promise(resolve => setTimeout(resolve, 10)); + lock.release(); + return id; + }; + + // Start multiple concurrent tasks + const tasks = [ + task(1), + task(2), + task(3) + ]; + + const r = await Promise.all(tasks); + + // All tasks should execute in order + expect(executionOrder).toEqual([1, 2, 3]); + expect(r).toEqual([1, 2, 3]); + }); + + test('no await for async function',async()=>{ + const results= [] + async function task(prefix, count) { + await lock.acquire() + let i = 0 + const h = setInterval(() => { + results.push(`${prefix}${i}`) + i++ + if (i >= count) { + lock.release() + clearInterval(h) + } + },10) + } + + task('a', 5) + task('b', 5) + task('c', 5) + await lock.acquire() + const r = results.join('') + expect(r.includes('a0a1a2a3a4')).toBe(true) + }) + }); +}); \ No newline at end of file diff --git a/__tests__/file-lock.test.js b/__tests__/file-lock.test.js new file mode 100644 index 0000000..3df373e --- /dev/null +++ b/__tests__/file-lock.test.js @@ -0,0 +1,146 @@ +const FileLock = require('../file-lock'); +const fs = require('fs'); +const path = require('path'); + +describe('FileLock', () => { + let lock; + const testFilePath = path.join(__dirname, 'test-file.txt'); + + beforeEach(() => { + lock = new FileLock(); + // Clean up any leftover lock files + const lockFilePath = `${testFilePath}.lock`; + if (fs.existsSync(lockFilePath)) { + fs.unlinkSync(lockFilePath); + } + // Create test file + fs.writeFileSync(testFilePath, 'test content'); + }); + + afterEach(() => { + // Clean up files + const lockFilePath = `${testFilePath}.lock`; + if (fs.existsSync(lockFilePath)) { + fs.unlinkSync(lockFilePath); + } + if (fs.existsSync(testFilePath)) { + fs.unlinkSync(testFilePath); + } + }); + + describe('acquire and release', () => { + test('should acquire lock without waiting when not locked', async () => { + const startTime = Date.now(); + await lock.acquire(testFilePath); + const endTime = Date.now(); + + // Should resolve almost immediately (no waiting) + expect(endTime - startTime).toBeLessThan(100); + + lock.release(testFilePath); + }); + + test('should protect file access without waiting when not locked', async () => { + let protectedFunctionCalled = false; + + const protectedFunction = async () => { + await lock.acquire(testFilePath); + protectedFunctionCalled = true; + // Simulate some async work + await new Promise(resolve => setTimeout(resolve, 10)); + lock.release(testFilePath); + return 'done'; + }; + + const result = await protectedFunction(); + + expect(protectedFunctionCalled).toBe(true); + expect(result).toBe('done'); + }); + + test('should allow consecutive acquisitions and releases without queueing', async () => { + // First acquisition + await lock.acquire(testFilePath); + lock.release(testFilePath); + + // Second acquisition + await lock.acquire(testFilePath); + lock.release(testFilePath); + + // Third acquisition + await lock.acquire(testFilePath); + lock.release(testFilePath); + }); + + test('should handle multiple concurrent acquisitions correctly', async () => { + let counter = 0; + + const incrementCounter = async () => { + await lock.acquire(testFilePath); + const temp = counter; + // Simulate some async work that could cause race condition + await new Promise(resolve => setTimeout(resolve, 1)); + counter = temp + 1; + lock.release(testFilePath); + }; + + // Run multiple concurrent operations + await Promise.all([ + incrementCounter(), + incrementCounter(), + incrementCounter() + ]); + + expect(counter).toBe(3); + }); + + test('should handle case where release is called without any pending acquirers', () => { + // Calling release on unlocked lock should not error + expect(() => lock.release(testFilePath)).not.toThrow(); + }); + + test('should properly queue multiple concurrent requests', async () => { + let executionOrder = []; + + const task = async (id) => { + await lock.acquire(testFilePath); + executionOrder.push(id); + // Simulate work + await new Promise(resolve => setTimeout(resolve, 10)); + lock.release(testFilePath); + }; + + // Start multiple concurrent tasks + const tasks = [ + task(1), + task(2), + task(3) + ]; + + await Promise.all(tasks); + + // All tasks should execute in order + expect(executionOrder).toEqual([1, 2, 3]); + }); + + test('should create lock file when acquiring lock', async () => { + await lock.acquire(testFilePath); + const lockFilePath = `${testFilePath}.lock`; + + expect(fs.existsSync(lockFilePath)).toBe(true); + expect(fs.readFileSync(lockFilePath, 'utf8')).toBe(process.pid.toString()); + + lock.release(testFilePath); + }); + + test('should remove lock file when releasing lock', async () => { + await lock.acquire(testFilePath); + const lockFilePath = `${testFilePath}.lock`; + + expect(fs.existsSync(lockFilePath)).toBe(true); + + lock.release(testFilePath); + expect(fs.existsSync(lockFilePath)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/async-lock.js b/async-lock.js new file mode 100644 index 0000000..26994cb --- /dev/null +++ b/async-lock.js @@ -0,0 +1,35 @@ +'use strict' + +class AsyncLock { + constructor() { + this.queue = [] + this.locked = false + } + + acquire(){ + return new Promise((resolve) => { + if (!this.locked) { + // No contention, acquire lock immediately + this.locked = true + resolve() + } else { + // Add to queue when lock is busy + this.queue.push(resolve) + } + }) + } + + release(){ + if (this.queue.length > 0) { + // Resolve the next queued promise + const nextResolve = this.queue.shift() + nextResolve() + // Keep locked status as true since we're passing the lock to the next waiter + } else { + // No waiters, unlock + this.locked = false + } + } +} + +module.exports = AsyncLock \ No newline at end of file diff --git a/file-lock.js b/file-lock.js new file mode 100644 index 0000000..cda2efa --- /dev/null +++ b/file-lock.js @@ -0,0 +1,109 @@ +'use strict' + +const fs = require('fs') +const path = require('path') + +class FileLock { + constructor() { + this.lockedFiles = new Map() + } + + /** + * Acquire a file-based lock + * @param {string} filePath - The path of the file to lock + * @returns {Promise} + */ + acquire(filePath) { + return new Promise((resolve, reject) => { + const lockFilePath = this._getLockFilePath(filePath) + + // Try to acquire lock immediately if not locked + if (!this.lockedFiles.has(filePath)) { + this._createLockFile(lockFilePath, filePath, resolve, reject) + } else { + // Add to queue if already locked + const queue = this.lockedFiles.get(filePath).queue + queue.push({ resolve, reject }) + } + }) + } + + /** + * Release a file-based lock + * @param {string} filePath - The path of the file to unlock + */ + release(filePath) { + if (!this.lockedFiles.has(filePath)) { + // Releasing an unlocked file is a no-op + return + } + + const lockInfo = this.lockedFiles.get(filePath) + const lockFilePath = this._getLockFilePath(filePath) + + // Remove lock file + try { + if (fs.existsSync(lockFilePath)) { + fs.unlinkSync(lockFilePath) + } + } catch (err) { + // Ignore errors during unlock + } + + // Check if there are queued requests + if (lockInfo.queue.length > 0) { + // Process the next queued request + const nextRequest = lockInfo.queue.shift() + this._createLockFile(lockFilePath, filePath, nextRequest.resolve, nextRequest.reject) + } else { + // No more queued requests, remove from locked files + this.lockedFiles.delete(filePath) + } + } + + /** + * Create a lock file + * @private + */ + _createLockFile(lockFilePath, filePath, resolve, reject) { + try { + // Use wx flag to atomically create the lock file + // If the file already exists, this will throw an error + fs.writeFileSync(lockFilePath, process.pid.toString(), { flag: 'wx' }) + + // Initialize queue for this file if not exists + if (!this.lockedFiles.has(filePath)) { + this.lockedFiles.set(filePath, { + lockFilePath: lockFilePath, + queue: [] + }) + } + + resolve() + } catch (err) { + if (err.code === 'EEXIST') { + // Lock file already exists, add to queue + if (!this.lockedFiles.has(filePath)) { + this.lockedFiles.set(filePath, { + lockFilePath: lockFilePath, + queue: [] + }) + } + this.lockedFiles.get(filePath).queue.push({ resolve, reject }) + } else { + reject(err) + } + } + } + + /** + * Get lock file path from original file path + * @private + */ + _getLockFilePath(filePath) { + const resolvedPath = path.resolve(filePath) + return `${resolvedPath}.lock` + } +} + +module.exports = FileLock \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..31c6af2 --- /dev/null +++ b/index.js @@ -0,0 +1,9 @@ +'use strict' + +const AsyncLock = require('./async-lock') +const FileLock = require('./file-lock') + +module.exports = { + AsyncLock, + FileLock +} \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..bb1cf22 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,200 @@ +/** + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +/** @type {import('jest').Config} */ +const config = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/tmp/jest_rs", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "mts", + // "cts", + // "tsx", + // "json", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + // testEnvironment: "jest-environment-node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.?([mc])[jt]s?(x)", + // "**/?(*.)+(spec|test).?([mc])[jt]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/", + // "\\.pnp\\.[^\\/]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; + +module.exports = config; diff --git a/package.json b/package.json new file mode 100644 index 0000000..35abb99 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "nlocks", + "version": "1.0.0", + "description": "A Node.js library providing asynchronous locking mechanisms including in-memory locks and file-based locks", + "main": "index.js", + "scripts": { + "test": "jest" + }, + "keywords": ["lock", "async", "file", "mutex", "synchronization", "concurrency"], + "author": "nlocks contributors", + "license": "MIT", + "devDependencies": { + "jest": "^30.2.0" + } +} \ No newline at end of file