/ opencodehub / src / lib / distributed-lock.ts
Swadhin Biswas Feature/ai code reviewer (#79) · 8/16/2026
cfd400d
218 lines · 6.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
/**
 * Distributed Lock Utility
 * Uses Redis for distributed locking across multiple instances
 * Implements a simple lock with TTL and retry mechanism
 */

import Redis from "ioredis";
import { logger } from "./logger";

export interface LockOptions {
    /** Lock timeout in seconds (default: 30) */
    ttlSeconds?: number;
    /** Retry attempts if lock is held (default: 10) */
    retryCount?: number;
    /** Delay between retries in ms (default: 100) */
    retryDelayMs?: number;
}

export interface Lock {
    key: string;
    token: string;
    fencingToken: number;
    release: () => Promise<boolean>;
}

export interface LockManager {
    acquire(key: string, options?: LockOptions): Promise<Lock | null>;
    destroy(): void;
}

let globalFencingToken = 0;

function nextFencingToken(): number {
  return ++globalFencingToken;
}

class InMemoryLockManager implements LockManager {
    private locks: Map<string, { token: string; expiresAt: number }> = new Map();
    private cleanupInterval: NodeJS.Timeout;

    constructor() {
        this.cleanupInterval = setInterval(() => this.cleanup(), 5000);
    }

    private cleanup(): void {
        const now = Date.now();
        for (const [key, lock] of this.locks.entries()) {
            if (now > lock.expiresAt) {
                this.locks.delete(key);
            }
        }
    }

    async acquire(key: string, options: LockOptions = {}): Promise<Lock | null> {
        const { ttlSeconds = 30, retryCount = 10, retryDelayMs = 100 } = options;
        const token = crypto.randomUUID();
        const fencingToken = nextFencingToken();
        const expiresAt = Date.now() + ttlSeconds * 1000;

        for (let attempt = 0; attempt <= retryCount; attempt++) {
            const existing = this.locks.get(key);
            if (!existing || Date.now() > existing.expiresAt) {
                this.locks.set(key, { token, expiresAt });
                return {
                    key,
                    token,
                    fencingToken,
                    release: async () => {
                        const current = this.locks.get(key);
                        if (current?.token === token) {
                            this.locks.delete(key);
                            return true;
                        }
                        return false;
                    },
                };
            }
            if (attempt < retryCount) {
                await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
            }
        }
        return null;
    }

    destroy(): void {
        clearInterval(this.cleanupInterval);
    }
}

const RELEASE_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
`;

const FENCING_SCRIPT = `
local current = redis.call("get", KEYS[1])
if current then
    local curToken = tonumber(current)
    local reqToken = tonumber(ARGV[2])
    if curToken and reqToken and reqToken >= curToken then
        redis.call("set", KEYS[1], ARGV[2], "EX", ARGV[3], "NX")
        return 1
    end
    return 0
else
    redis.call("set", KEYS[1], ARGV[2], "EX", ARGV[3])
    return 1
end
`;

class RedisLockManager implements LockManager {
    constructor(private readonly redis: Redis) {}

    async acquire(key: string, options: LockOptions = {}): Promise<Lock | null> {
        const { ttlSeconds = 30, retryCount = 10, retryDelayMs = 100 } = options;
        const lockKey = `lock:${key}`;
        const token = String(nextFencingToken());

        for (let attempt = 0; attempt <= retryCount; attempt++) {
            try {
                const result = await this.redis.set(lockKey, token, "EX", ttlSeconds, "NX");
                if (result === "OK") {
                    logger.debug({ key, fencingToken: token, ttl: ttlSeconds }, "Lock acquired");
                    return {
                        key,
                        token,
                        fencingToken: Number(token),
                        release: async () => this.release(lockKey, token),
                    };
                }
                if (attempt < retryCount) {
                    await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
                }
            } catch (error) {
                logger.error({ error, key }, "Error acquiring lock");
                return {
                    key,
                    token: "fallback",
                    fencingToken: 0,
                    release: async () => true,
                };
            }
        }
        logger.warn({ key, attempts: retryCount + 1 }, "Failed to acquire lock after retries");
        return null;
    }

    private async release(lockKey: string, token: string): Promise<boolean> {
        try {
            const result = await this.redis.eval(RELEASE_SCRIPT, 1, lockKey, token);
            const released = result === 1;
            logger.debug({ key: lockKey, released }, "Lock release attempt");
            return released;
        } catch (error) {
            logger.error({ error, key: lockKey }, "Error releasing lock");
            return false;
        }
    }

    destroy(): void {
        // Redis handles connections automatically
    }
}

let cached: LockManager | null = null;

function getManager(): LockManager {
    if (cached) return cached;
    const skipRedis = process.env.SKIP_REDIS_CHECK === "1" || process.env.NODE_ENV === "test";
    if (skipRedis) {
        logger.warn("Using in-memory lock manager (Redis skipped). NOT SAFE for multi-instance deployments.");
        cached = new InMemoryLockManager();
        return cached;
    }
    try {
        const url = process.env.REDIS_URL || "redis://localhost:6379";
        const client: Redis = new Redis(url, {
            enableReadyCheck: false,
            maxRetriesPerRequest: 1,
            lazyConnect: true,
        });
        cached = new RedisLockManager(client);
    } catch (e) {
        logger.warn({ error: e }, "Redis init failed — using in-memory lock manager");
        cached = new InMemoryLockManager();
    }
    return cached;
}

const lockManager = getManager();

export const isDistributedLocking = !(lockManager instanceof InMemoryLockManager);

export async function acquireLock(key: string, options?: LockOptions): Promise<Lock | null> {
    return lockManager.acquire(key, options);
}

export async function withLock<T>(
    key: string,
    fn: () => Promise<T>,
    options?: LockOptions,
): Promise<T> {
    const lock = await acquireLock(key, options);
    if (!lock) {
        throw new Error(`Failed to acquire lock for key: ${key}`);
    }
    try {
        return await fn();
    } finally {
        await lock.release();
    }
}

export default lockManager;