AIClient-2-API/src/utils/file-lock.js
hex2077 5c90100997 feat(并发控制): 实现去重锁机制并优化提供者选择并发安全
为关键操作添加去重锁机制,确保并发请求只执行一次共享结果
重构 provider-pool-manager 使用链式锁保证选择操作的原子性
更新各服务提供者使用 withDeduplication 替代自定义单例锁
2026-01-14 16:01:04 +08:00

220 lines
No EOL
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as path from 'path';
/**
* 文件锁管理器 - 防止并发写入导致文件损坏
*
* 使用场景:
* - 多个异步操作同时读写同一文件
* - 防止读-改-写竞争条件Race Condition
* - 防止写入交错导致文件内容损坏
*
* 注意:这是进程内锁,只能防止同一 Node.js 进程内的并发。
* 如果需要跨进程文件锁,请使用 proper-lockfile 等库。
*/
// 存储每个文件的锁队列Promise 链)
// 每个文件对应一个 Promise新的锁请求会链接到当前 Promise 之后
const fileLockQueues = new Map();
// 存储去重锁的进行中 Promise
// 用于合并相同 key 的并发请求,只执行一次操作
const dedupePromises = new Map();
/**
* 获取文件锁,确保同一时间只有一个操作可以访问特定文件
*
* 实现原理:使用 Promise 链实现队列机制
* - 每个文件维护一个 Promise 链
* - 新的锁请求会等待当前链完成,然后创建新的链节点
* - 这确保了锁的获取是严格串行的,避免竞态条件
*
* @param {string} filePath - 文件路径
* @returns {Promise<() => void>} 释放锁的函数
*
* @example
* const releaseLock = await acquireFileLock('/path/to/file.json');
* try {
* // 读取、修改、写入文件
* const data = await fs.readFile(filePath, 'utf8');
* const modified = JSON.parse(data);
* modified.key = 'new value';
* await fs.writeFile(filePath, JSON.stringify(modified, null, 2));
* } finally {
* releaseLock(); // 确保锁被释放
* }
*/
export async function acquireFileLock(filePath) {
const normalizedPath = path.resolve(filePath);
// 获取当前队列中的最后一个 Promise如果存在
const currentLock = fileLockQueues.get(normalizedPath) || Promise.resolve();
// 创建释放锁的 resolver
let releaseLock;
const newLockPromise = new Promise(resolve => {
releaseLock = resolve;
});
// 立即将新的 Promise 加入队列(在 await 之前!)
// 这是关键:确保后续请求会等待这个新的 Promise
fileLockQueues.set(normalizedPath, newLockPromise);
// 等待前一个锁释放
await currentLock;
// 返回释放锁的函数
return () => {
// 只有当当前锁仍是队列中的最后一个时才清理
// 否则保留队列让后续请求继续等待
if (fileLockQueues.get(normalizedPath) === newLockPromise) {
fileLockQueues.delete(normalizedPath);
}
releaseLock();
};
}
/**
* 使用文件锁执行操作的便捷函数
* @param {string} filePath - 文件路径
* @param {Function} operation - 要执行的异步操作
* @returns {Promise<any>} 操作的返回值
*
* @example
* const result = await withFileLock('/path/to/file.json', async () => {
* const data = await fs.readFile(filePath, 'utf8');
* const modified = JSON.parse(data);
* modified.key = 'new value';
* await fs.writeFile(filePath, JSON.stringify(modified, null, 2));
* return modified;
* });
*/
export async function withFileLock(filePath, operation) {
const releaseLock = await acquireFileLock(filePath);
try {
return await operation();
} finally {
releaseLock();
}
}
/**
* 检查文件是否被锁定(有等待中的锁队列)
* @param {string} filePath - 文件路径
* @returns {boolean} 是否被锁定
*/
export function isFileLocked(filePath) {
const normalizedPath = path.resolve(filePath);
return fileLockQueues.has(normalizedPath);
}
/**
* 获取当前被锁定的文件数量(用于调试)
* @returns {number} 被锁定的文件数量
*/
export function getLockedFileCount() {
return fileLockQueues.size;
}
/**
* 去重执行 - 合并相同 key 的并发请求,只执行一次操作
*
* 与 withFileLock 的区别:
* - withFileLock队列锁10个并发请求 → 排队执行10次
* - withDeduplication去重锁10个并发请求 → 只执行1次共享结果
*
* 使用场景:
* - Token 刷新:多个请求同时发现 token 过期,只需刷新一次
* - 缓存填充:多个请求同时 cache miss只需加载一次
* - 任何"结果可共享"的昂贵操作
*
* @param {string} key - 去重的唯一标识符
* @param {Function} operation - 要执行的异步操作
* @returns {Promise<any>} 操作的返回值(所有等待者共享同一结果)
*
* @example
* // 多个并发调用只会执行一次 refreshToken
* const newToken = await withDeduplication('token-refresh', async () => {
* const response = await fetch('/refresh');
* return response.json();
* });
*/
export async function withDeduplication(key, operation) {
// 如果已有相同 key 的操作在进行中,直接等待它的结果
if (dedupePromises.has(key)) {
return dedupePromises.get(key);
}
// 创建新的操作 Promise
const operationPromise = (async () => {
try {
return await operation();
} finally {
// 操作完成后清理
dedupePromises.delete(key);
}
})();
// 存入 Map让后续请求可以共享
dedupePromises.set(key, operationPromise);
return operationPromise;
}
/**
* 组合去重锁和文件锁 - 先去重再加文件锁
*
* 典型场景Token 刷新
* 1. 去重层10个并发刷新请求 → 合并为1次刷新操作
* 2. 文件锁层保护那1次刷新操作的文件写入不与其他操作冲突
*
* @param {string} dedupeKey - 去重的唯一标识符
* @param {string} filePath - 需要保护的文件路径
* @param {Function} operation - 要执行的异步操作
* @returns {Promise<any>} 操作的返回值
*
* @example
* // Token 刷新场景
* const newToken = await withDeduplicationAndFileLock(
* 'token-refresh-' + credentialId,
* '/path/to/token.json',
* async () => {
* const response = await fetch('/refresh');
* const data = await response.json();
* await fs.writeFile('/path/to/token.json', JSON.stringify(data));
* return data;
* }
* );
*/
export async function withDeduplicationAndFileLock(dedupeKey, filePath, operation) {
return withDeduplication(dedupeKey, async () => {
return withFileLock(filePath, operation);
});
}
/**
* 检查是否有去重操作正在进行
* @param {string} key - 去重的唯一标识符
* @returns {boolean} 是否有操作在进行中
*/
export function isDedupeInProgress(key) {
return dedupePromises.has(key);
}
/**
* 获取当前进行中的去重操作数量(用于调试)
* @returns {number} 进行中的去重操作数量
*/
export function getDedupeCount() {
return dedupePromises.size;
}
export default {
acquireFileLock,
withFileLock,
isFileLocked,
getLockedFileCount,
withDeduplication,
withDeduplicationAndFileLock,
isDedupeInProgress,
getDedupeCount
};