feat(init): 初始化项目结构和基础代码

添加了异步锁库的基础实现,包括内存锁和文件锁功能。
- 新增 `AsyncLock` 类用于进程内异步加锁
- 新增 `FileLock` 类用于跨进程的文件锁机制
- 添加单元测试覆盖核心逻辑
- 配置 Jest 测试环境并启用覆盖率收集
- 创建 README 文档说明安装、使用方法与 API 详情
- 添加 .gitignore 忽略构建产物及敏感文件
- 添加 MIT 许可证声明

该提交涵盖了项目的初始设置以及基本功能的完整实现。
This commit is contained in:
kingecg 2025-11-13 22:32:05 +08:00
commit d3637a21c5
10 changed files with 785 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -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
</import>

21
LICENSE Normal file
View File

@ -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.

96
README.md Normal file
View File

@ -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<void>` - Acquires the lock
- `release(): void` - Releases the lock
### FileLock
- `new FileLock()` - Creates a new FileLock instance
- `acquire(filePath): Promise<void>` - 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)

View File

@ -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)
})
});
});

146
__tests__/file-lock.test.js Normal file
View File

@ -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);
});
});
});

35
async-lock.js Normal file
View File

@ -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

109
file-lock.js Normal file
View File

@ -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<void>}
*/
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

9
index.js Normal file
View File

@ -0,0 +1,9 @@
'use strict'
const AsyncLock = require('./async-lock')
const FileLock = require('./file-lock')
module.exports = {
AsyncLock,
FileLock
}

200
jest.config.js Normal file
View File

@ -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: [
// "<rootDir>"
// ],
// 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;

15
package.json Normal file
View File

@ -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"
}
}