Skip to content

Commit ceb5f1c

Browse files
committed
fix: improve file lock fallback
- Remove retries from sync lock (not supported) - Handle Windows path for tmp directory - Ignore ENOENT when releasing - Add fallback unit tests
1 parent 0396651 commit ceb5f1c

File tree

2 files changed

+109
-11
lines changed

2 files changed

+109
-11
lines changed

src/redis-lock.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,16 @@
88
import Redis from 'ioredis';
99
import path from 'node:path';
1010
import fs from 'node:fs';
11-
import proper-lockfile from 'proper-lockfile';
11+
12+
// 用 lazy import 避免 ESM 問題
13+
let properLockfile: any = null;
14+
15+
async function loadProperLockfile(): Promise<any> {
16+
if (!properLockfile) {
17+
properLockfile = await import('proper-lockfile');
18+
}
19+
return properLockfile;
20+
}
1221

1322
// 生成唯一 token
1423
function generateToken(): string {
@@ -135,7 +144,9 @@ export class RedisLockManager {
135144
* 建立 file lock(Redis 不可用時的 fallback)
136145
*/
137146
private createFileLock(key: string, ttl?: number): () => Promise<void> {
138-
const lockPath = path.join('/tmp', `.memory-lock-${key}.lock`);
147+
// Windows tmp 目錄
148+
const tmpDir = process.platform === 'win32' ? 'C:\\tmp' : '/tmp';
149+
const lockPath = path.join(tmpDir, `.memory-lock-${key}.lock`);
139150
const lockTTL = (ttl || this.defaultTTL) / 1000; // proper-lockfile 用秒
140151

141152
// 確保目錄存在
@@ -144,14 +155,10 @@ export class RedisLockManager {
144155
fs.mkdirSync(dir, { recursive: true });
145156
}
146157

147-
// 同步取得 file lock
158+
// 同步取得 file lock(不支援 retries)
148159
try {
149-
proper_lockfile.lockSync(lockPath, {
150-
retries: {
151-
retries: 10,
152-
minTimeout: 1000,
153-
maxTimeout: 30000,
154-
},
160+
const lockfile = require('proper-lockfile');
161+
lockfile.lockSync(lockPath, {
155162
stale: lockTTL,
156163
});
157164
console.log(`[RedisLock] Acquired file lock for ${key}`);
@@ -162,10 +169,14 @@ export class RedisLockManager {
162169
// 回傳 release function
163170
return async () => {
164171
try {
165-
await proper_lockfile.unlock(lockPath);
172+
const lockfile = require('proper-lockfile');
173+
await lockfile.unlock(lockPath);
166174
console.log(`[RedisLock] Released file lock for ${key}`);
167175
} catch (err) {
168-
console.warn(`[RedisLock] Failed to release file lock: ${err}`);
176+
// 忽略 ENOENT(檔案不存在)
177+
if (!err.message.includes('ENOENT')) {
178+
console.warn(`[RedisLock] Failed to release file lock: ${err}`);
179+
}
169180
}
170181
};
171182
}

test/redis-lock-fallback.test.mjs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// test/redis-lock-fallback.test.mjs
2+
/**
3+
* Redis Lock Fallback 測試
4+
*
5+
* 測試當 Redis 不可用時,是否會正確 fallback 到 file lock
6+
*/
7+
8+
import { describe, it } from "node:test";
9+
import assert from "node:assert/strict";
10+
import { mkdtempSync, rmSync } from "node:fs";
11+
import { tmpdir } from "node:os";
12+
import { join } from "node:path";
13+
import jitiFactory from "jiti";
14+
15+
const jiti = jitiFactory(import.meta.url, { interopDefault: true });
16+
const { RedisLockManager } = jiti("../src/redis-lock.ts");
17+
18+
describe("Redis Lock Fallback", () => {
19+
// 測試 1:Redis 不可用時使用 file lock
20+
it("should fallback to file lock when Redis unavailable", async () => {
21+
// 故意用一個不會有 Redis 的 URL
22+
const manager = new RedisLockManager({
23+
redisUrl: 'redis://localhost:9999', // 不存在的 Redis
24+
ttl: 5000,
25+
maxWait: 5000,
26+
});
27+
28+
const release = await manager.acquire("fallback-test-key");
29+
30+
// 應該成功取得 lock(file lock fallback)
31+
assert.ok(release, "Should return a release function");
32+
33+
// 執行 release
34+
await release();
35+
36+
console.log("[Fallback test] Successfully used file lock fallback");
37+
});
38+
39+
// 測試 2:多次取得不同 key 的 lock
40+
it("should handle multiple locks with fallback", async () => {
41+
const manager = new RedisLockManager({
42+
redisUrl: 'redis://localhost:9999',
43+
ttl: 3000,
44+
});
45+
46+
const locks = [];
47+
for (let i = 0; i < 3; i++) {
48+
const release = await manager.acquire(`fallback-multi-${i}`);
49+
locks.push(release);
50+
}
51+
52+
// 應該成功取得 3 個 lock
53+
assert.strictEqual(locks.length, 3, "Should acquire 3 locks");
54+
55+
// 全部 release
56+
for (const release of locks) {
57+
await release();
58+
}
59+
60+
console.log("[Fallback test] Multiple locks handled successfully");
61+
});
62+
63+
// 測試 3:file lock 的 TTL 行為
64+
it("should respect TTL in file lock fallback", async () => {
65+
const shortTTL = 1000; // 1 秒
66+
67+
const manager = new RedisLockManager({
68+
redisUrl: 'redis://localhost:9999',
69+
ttl: shortTTL,
70+
});
71+
72+
const release = await manager.acquire("fallback-ttl-test");
73+
74+
// 等待 TTL 過期
75+
await new Promise(r => setTimeout(r, shortTTL + 500));
76+
77+
// 應該可以再次取得同一個 key(因為 TTL 過期了)
78+
const release2 = await manager.acquire("fallback-ttl-test");
79+
80+
await release();
81+
await release2();
82+
83+
console.log("[Fallback test] TTL respected in file lock");
84+
});
85+
});
86+
87+
console.log("=== Redis Lock Fallback Tests ===");

0 commit comments

Comments
 (0)