@nocobase/lock-manager 2.1.0-beta.7 → 2.1.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,9 +7,20 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
  export type Releaser = () => void | Promise<void>;
10
+ /**
11
+ * A lock handle returned by {@link ILockAdapter.tryAcquire}.
12
+ *
13
+ * **Important**: the underlying mutex is already held when this object is
14
+ * returned. The caller MUST invoke one of `acquire()`, `runExclusive()`, or
15
+ * `release()` promptly; if none is called the lock will be held indefinitely.
16
+ *
17
+ * Use `release()` to abandon the lock without executing any work (e.g. when an
18
+ * early-return or error prevents the caller from proceeding).
19
+ */
10
20
  export interface ILock {
11
21
  acquire(ttl: number): Releaser | Promise<Releaser>;
12
22
  runExclusive<T>(fn: () => Promise<T>, ttl: number): Promise<T>;
23
+ release(): void | Promise<void>;
13
24
  }
14
25
  export interface ILockAdapter {
15
26
  connect(): Promise<void>;
@@ -41,6 +52,6 @@ export declare class LockManager {
41
52
  close(): Promise<void>;
42
53
  acquire(key: string, ttl?: number): Promise<Releaser>;
43
54
  runExclusive<T>(key: string, fn: () => Promise<T>, ttl?: number): Promise<T>;
44
- tryAcquire(key: string): Promise<ILock>;
55
+ tryAcquire(key: string, timeout?: number): Promise<ILock>;
45
56
  }
46
57
  export default LockManager;
@@ -97,17 +97,69 @@ const _LocalLockAdapter = class _LocalLockAdapter {
97
97
  clearTimeout(timer);
98
98
  }
99
99
  }
100
- async tryAcquire(key) {
101
- const lock = this.getLock(key);
102
- if (lock.isLocked()) {
103
- throw new LockAcquireError("lock is locked");
100
+ async tryAcquire(key, timeout = 0) {
101
+ const mutex = this.getLock(key);
102
+ let preAcquiredRelease;
103
+ if (timeout === 0) {
104
+ if (mutex.isLocked()) {
105
+ throw new LockAcquireError("lock is locked");
106
+ }
107
+ preAcquiredRelease = await mutex.acquire();
108
+ } else {
109
+ try {
110
+ preAcquiredRelease = await (0, import_async_mutex.withTimeout)(mutex, timeout).acquire();
111
+ } catch (e) {
112
+ throw new LockAcquireError("lock acquire timed out", { cause: e });
113
+ }
104
114
  }
115
+ let preAcquiredConsumed = false;
116
+ const getRelease = /* @__PURE__ */ __name(async () => {
117
+ const rawRelease = !preAcquiredConsumed ? (preAcquiredConsumed = true, preAcquiredRelease) : await mutex.acquire();
118
+ let released = false;
119
+ return () => {
120
+ if (!released) {
121
+ released = true;
122
+ return rawRelease();
123
+ }
124
+ };
125
+ }, "getRelease");
105
126
  return {
127
+ release: /* @__PURE__ */ __name(async () => {
128
+ const release = await getRelease();
129
+ await release();
130
+ }, "release"),
106
131
  acquire: /* @__PURE__ */ __name(async (ttl) => {
107
- return this.acquire(key, ttl);
132
+ const release = await getRelease();
133
+ const timer = setTimeout(() => {
134
+ if (mutex.isLocked()) {
135
+ release();
136
+ }
137
+ }, ttl);
138
+ return () => {
139
+ release();
140
+ clearTimeout(timer);
141
+ };
108
142
  }, "acquire"),
109
143
  runExclusive: /* @__PURE__ */ __name(async (fn, ttl) => {
110
- return this.runExclusive(key, fn, ttl);
144
+ const release = await getRelease();
145
+ let timer;
146
+ try {
147
+ timer = setTimeout(() => {
148
+ if (mutex.isLocked()) {
149
+ release();
150
+ }
151
+ }, ttl);
152
+ return await fn();
153
+ } catch (e) {
154
+ if (e === import_async_mutex.E_CANCELED) {
155
+ throw new LockAbortError("Lock aborted", { cause: import_async_mutex.E_CANCELED });
156
+ } else {
157
+ throw e;
158
+ }
159
+ } finally {
160
+ clearTimeout(timer);
161
+ release();
162
+ }
111
163
  }, "runExclusive")
112
164
  };
113
165
  }
@@ -155,9 +207,9 @@ const _LockManager = class _LockManager {
155
207
  const client = await this.getAdapter();
156
208
  return client.runExclusive(key, fn, ttl);
157
209
  }
158
- async tryAcquire(key) {
210
+ async tryAcquire(key, timeout = 0) {
159
211
  const client = await this.getAdapter();
160
- return client.tryAcquire(key);
212
+ return client.tryAcquire(key, timeout);
161
213
  }
162
214
  };
163
215
  __name(_LockManager, "LockManager");
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@nocobase/lock-manager",
3
- "version": "2.1.0-beta.7",
3
+ "version": "2.1.0-beta.9",
4
4
  "main": "lib/index.js",
5
5
  "license": "Apache-2.0",
6
6
  "devDependencies": {
7
- "@nocobase/utils": "2.1.0-beta.7",
7
+ "@nocobase/utils": "2.1.0-beta.9",
8
8
  "async-mutex": "^0.5.0"
9
9
  },
10
- "gitHead": "da7dfef2b6d6854988a56119463c8c38e3221e79"
10
+ "gitHead": "c3a2875e4cbbb43b1f2361e6f9f5f84a7d3f3c3c"
11
11
  }
@@ -165,5 +165,71 @@ describe('lock manager', () => {
165
165
  await r2();
166
166
  expect(order).toEqual([1, 2, 3, 4]);
167
167
  });
168
+
169
+ it('tryAcquire: only one concurrent caller wins (TOCTOU race)', async () => {
170
+ // Simulate two nodes calling tryAcquire at the same time (single-process,
171
+ // like createMockCluster). Only one should succeed; the other must throw.
172
+ const results: Array<'acquired' | 'skipped'> = [];
173
+ await Promise.all(
174
+ [0, 1].map(async () => {
175
+ try {
176
+ const lock = await lockManager.tryAcquire('race-test');
177
+ const release = await lock.acquire(1000);
178
+ results.push('acquired');
179
+ await release();
180
+ } catch (e) {
181
+ if (e instanceof LockAcquireError) {
182
+ results.push('skipped');
183
+ } else {
184
+ throw e;
185
+ }
186
+ }
187
+ }),
188
+ );
189
+ expect(results.filter((r) => r === 'acquired').length).toBe(1);
190
+ expect(results.filter((r) => r === 'skipped').length).toBe(1);
191
+ });
192
+
193
+ it('tryAcquire: waits up to timeout ms before throwing when lock is held', async () => {
194
+ // Acquire the lock manually so it is held during the tryAcquire call.
195
+ const holdRelease = await lockManager.acquire('wait-test');
196
+
197
+ // Release the lock after 50 ms — tryAcquire with timeout=1000 should
198
+ // succeed because the lock becomes available well within the window.
199
+ // Using a large margin (50ms release vs 1000ms timeout) to avoid flakiness
200
+ // on slow CI machines under load.
201
+ setTimeout(() => holdRelease(), 50);
202
+ const lock = await lockManager.tryAcquire('wait-test', 1000);
203
+ const release = await lock.acquire(1000);
204
+ await release();
205
+ });
206
+
207
+ it('tryAcquire: throws LockAcquireError when timeout expires before lock is released', async () => {
208
+ const holdRelease = await lockManager.acquire('timeout-test');
209
+ // Lock is held; tryAcquire with a very short timeout should fail.
210
+ await expect(lockManager.tryAcquire('timeout-test', 50)).rejects.toThrowError(LockAcquireError);
211
+ await holdRelease();
212
+ });
213
+
214
+ it('tryAcquire: subsequent call succeeds after previous lock is released', async () => {
215
+ const lock1 = await lockManager.tryAcquire('seq-test');
216
+ const release = await lock1.acquire(1000);
217
+ await release();
218
+ // After release, a second tryAcquire on the same key should succeed
219
+ const lock2 = await lockManager.tryAcquire('seq-test');
220
+ const release2 = await lock2.acquire(1000);
221
+ await release2();
222
+ });
223
+
224
+ it('tryAcquire: release() abandons the pre-acquired lock without executing work', async () => {
225
+ // Acquire the lock via tryAcquire, then immediately release it.
226
+ const lock = await lockManager.tryAcquire('cancel-test');
227
+ await lock.release();
228
+
229
+ // The lock should now be free; a subsequent tryAcquire must succeed.
230
+ const lock2 = await lockManager.tryAcquire('cancel-test');
231
+ const r = await lock2.acquire(1000);
232
+ await r();
233
+ });
168
234
  });
169
235
  });
@@ -8,13 +8,24 @@
8
8
  */
9
9
 
10
10
  import { Registry } from '@nocobase/utils';
11
- import { Mutex, MutexInterface, E_CANCELED } from 'async-mutex';
11
+ import { Mutex, MutexInterface, E_CANCELED, withTimeout } from 'async-mutex';
12
12
 
13
13
  export type Releaser = () => void | Promise<void>;
14
14
 
15
+ /**
16
+ * A lock handle returned by {@link ILockAdapter.tryAcquire}.
17
+ *
18
+ * **Important**: the underlying mutex is already held when this object is
19
+ * returned. The caller MUST invoke one of `acquire()`, `runExclusive()`, or
20
+ * `release()` promptly; if none is called the lock will be held indefinitely.
21
+ *
22
+ * Use `release()` to abandon the lock without executing any work (e.g. when an
23
+ * early-return or error prevents the caller from proceeding).
24
+ */
15
25
  export interface ILock {
16
26
  acquire(ttl: number): Releaser | Promise<Releaser>;
17
27
  runExclusive<T>(fn: () => Promise<T>, ttl: number): Promise<T>;
28
+ release(): void | Promise<void>;
18
29
  }
19
30
 
20
31
  export interface ILockAdapter {
@@ -87,17 +98,85 @@ class LocalLockAdapter implements ILockAdapter {
87
98
  }
88
99
  }
89
100
 
90
- async tryAcquire(key: string) {
91
- const lock = this.getLock(key);
92
- if (lock.isLocked()) {
93
- throw new LockAcquireError('lock is locked');
101
+ async tryAcquire(key: string, timeout = 0) {
102
+ const mutex = this.getLock(key);
103
+ let preAcquiredRelease: Releaser;
104
+
105
+ if (timeout === 0) {
106
+ // Non-blocking: throw immediately if the lock is already held.
107
+ // mutex.acquire() is called synchronously (before any await boundary) so
108
+ // that _locked=true is set atomically within the current JS execution
109
+ // slice, preventing TOCTOU races in single-process cluster simulations
110
+ // (e.g. tests using createMockCluster).
111
+ if (mutex.isLocked()) {
112
+ throw new LockAcquireError('lock is locked');
113
+ }
114
+ preAcquiredRelease = (await mutex.acquire()) as Releaser;
115
+ } else {
116
+ // Blocking with timeout: wait up to `timeout` ms for the lock, then
117
+ // throw. withTimeout() from async-mutex handles queue cleanup properly
118
+ // when the timeout fires before the lock is acquired.
119
+ try {
120
+ preAcquiredRelease = (await withTimeout(mutex, timeout).acquire()) as Releaser;
121
+ } catch (e) {
122
+ throw new LockAcquireError('lock acquire timed out', { cause: e });
123
+ }
94
124
  }
125
+
126
+ let preAcquiredConsumed = false;
127
+
128
+ const getRelease = async (): Promise<Releaser> => {
129
+ const rawRelease: Releaser = !preAcquiredConsumed
130
+ ? ((preAcquiredConsumed = true), preAcquiredRelease)
131
+ : ((await mutex.acquire()) as Releaser);
132
+ // Idempotency guard: prevents double-release when both the TTL auto-
133
+ // release timer and the caller-facing releaser (or finally block) fire.
134
+ let released = false;
135
+ return () => {
136
+ if (!released) {
137
+ released = true;
138
+ return (rawRelease as () => void | Promise<void>)();
139
+ }
140
+ };
141
+ };
142
+
95
143
  return {
96
- acquire: async (ttl) => {
97
- return this.acquire(key, ttl);
144
+ release: async (): Promise<void> => {
145
+ const release = await getRelease();
146
+ await release();
147
+ },
148
+ acquire: async (ttl: number): Promise<Releaser> => {
149
+ const release = await getRelease();
150
+ const timer: ReturnType<typeof setTimeout> = setTimeout(() => {
151
+ if (mutex.isLocked()) {
152
+ release();
153
+ }
154
+ }, ttl);
155
+ return () => {
156
+ release();
157
+ clearTimeout(timer);
158
+ };
98
159
  },
99
- runExclusive: async (fn: () => Promise<any>, ttl) => {
100
- return this.runExclusive(key, fn, ttl);
160
+ runExclusive: async <T>(fn: () => Promise<T>, ttl: number): Promise<T> => {
161
+ const release = await getRelease();
162
+ let timer: ReturnType<typeof setTimeout>;
163
+ try {
164
+ timer = setTimeout(() => {
165
+ if (mutex.isLocked()) {
166
+ release();
167
+ }
168
+ }, ttl);
169
+ return await fn();
170
+ } catch (e) {
171
+ if (e === E_CANCELED) {
172
+ throw new LockAbortError('Lock aborted', { cause: E_CANCELED });
173
+ } else {
174
+ throw e;
175
+ }
176
+ } finally {
177
+ clearTimeout(timer);
178
+ release();
179
+ }
101
180
  },
102
181
  };
103
182
  }
@@ -160,9 +239,9 @@ export class LockManager {
160
239
  return client.runExclusive(key, fn, ttl);
161
240
  }
162
241
 
163
- public async tryAcquire(key: string) {
242
+ public async tryAcquire(key: string, timeout = 0) {
164
243
  const client = await this.getAdapter();
165
- return client.tryAcquire(key);
244
+ return client.tryAcquire(key, timeout);
166
245
  }
167
246
  }
168
247