Skip to content

Commit

Permalink
feat: 🎸 improve locking interface
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich committed Apr 1, 2024
1 parent 8901416 commit 171797b
Show file tree
Hide file tree
Showing 2 changed files with 89 additions and 27 deletions.
53 changes: 26 additions & 27 deletions src/Locks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const defaultState = typeof window === 'object' && window && (typeof window.localStorage === 'object') ? window.localStorage : {};
const defaultStore = typeof window === 'object' && window && (typeof window.localStorage === 'object') ? window.localStorage : null;

let _locks: Locks | undefined;

/**
* Creates a lock manager, which can create exclusive locks across browser tabs.
Expand All @@ -10,45 +12,41 @@ const defaultState = typeof window === 'object' && window && (typeof window.loca
* within the 5 seconds. The lock will acquired for 2 seconds (default 1000ms).
*
* ```ts
* const locks = new Locks();
*
* locks.lock('my-lock', 2000, 5000, async () => {
* Locks.get().lock('my-lock', 2000, 5000)(async () => {
* console.log('Lock acquired');
* });
* ```
*/
export class Locks {
public static get = (): Locks => {
if (!_locks) _locks = new Locks();
return _locks;
};

constructor (
protected readonly state: Record<string, string> = defaultState,
protected readonly store: Record<string, string> = defaultStore || {},
protected readonly now = Date.now,
protected readonly pfx = 'lock-',
) {}

public acquire(id: string, ms = 1000): boolean {
public acquire(id: string, ms = 1000): (() => void) | undefined {
if (ms <= 0) return;
const key = this.pfx + id;
const lockUntil = this.state[key];
const lockUntil = this.store[key];
const now = this.now();
if (lockUntil === undefined) {
this.state[key] = (now + ms).toString(36);
return true;
}
const lockUntilNum = parseInt(lockUntil, 36);
if (lockUntilNum > now) return false;
const isLocked = lockUntil !== undefined && parseInt(lockUntil, 36) > now;
if (isLocked) return;
const lockUntilNex = (now + ms).toString(36);
this.state[key] = lockUntilNex;
return true;
}

public release(id: string): boolean {
const key = this.pfx + id;
if (this.state[key] === undefined) return false;
delete this.state[key];
return true;
this.store[key] = lockUntilNex;
const unlock = () => {
if (this.store[key] === lockUntilNex) delete this.store[key];
};
return unlock;
}

public isLocked(id: string): boolean {
const key = this.pfx + id;
const lockUntil = this.state[key];
const lockUntil = this.store[key];
if (lockUntil === undefined) return false;
const now = this.now();
const lockUntilNum = parseInt(lockUntil, 36);
Expand All @@ -58,16 +56,17 @@ export class Locks {
public lock(id: string, ms?: number, timeoutMs: number = 2 * 1000, checkMs: number = 10): (<T>(fn: () => Promise<T>) => Promise<T>) {
return async <T>(fn: () => Promise<T>): Promise<T> => {
const timeout = this.now() + timeoutMs;
while (true) {
const acquired = this.acquire(id, ms);
if (acquired) break;
let unlock: (() => void) | undefined;
while (!unlock) {
unlock = this.acquire(id, ms);
if (unlock) break;
await new Promise(r => setTimeout(r, checkMs));
if (this.now() > timeout) throw new Error('LOCK_TIMEOUT');
}
try {
return await fn();
} finally {
this.release(id);
unlock!();
}
};
}
Expand Down
63 changes: 63 additions & 0 deletions src/__tests__/Locks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {Locks} from '../Locks';
import {tick} from '../tick';

describe('.acquire()', () => {
test('should return true on first call', () => {
const locks = new Locks({});
const unlock = locks.acquire('my-lock');
expect(unlock).toBeInstanceOf(Function);
});

test('should not lock on second calls', () => {
const locks = new Locks({});
const unlock1 = locks.acquire('my-lock');
const unlock2 = locks.acquire('my-lock');
const unlock3 = locks.acquire('my-lock');
expect(!!unlock1).toBe(true);
expect(!!unlock2).toBe(false);
expect(!!unlock3).toBe(false);
});

test('can acquire lock which was released', () => {
const locks = new Locks({});
const unlock1 = locks.acquire('my-lock');
unlock1!();
const unlock2 = locks.acquire('my-lock');
expect(!!unlock1).toBe(true);
expect(!!unlock2).toBe(true);
});

test('can release lock before timeout', async () => {
const locks = new Locks({});
const unlock1 = locks.acquire('my-lock', 100);
const unlock2 = locks.acquire('my-lock');
expect(!!unlock1).toBe(true);
expect(!!unlock2).toBe(false);
const unlock3 = locks.acquire('my-lock');
expect(!!unlock3).toBe(false);
await tick(2);
const unlock4 = locks.acquire('my-lock');
expect(!!unlock4).toBe(false);
unlock1!();
const unlock5 = locks.acquire('my-lock');
expect(!!unlock5).toBe(true);
});

test('cannot release other expired lock', async () => {
const locks = new Locks({});
const unlock1 = locks.acquire('my-lock', 20);
expect(!!unlock1).toBe(true);
expect(locks.isLocked('my-lock')).toBe(true);
await tick(21);
expect(locks.isLocked('my-lock')).toBe(false);
const unlock2 = locks.acquire('my-lock', 1);
expect(!!unlock2).toBe(true);
expect(locks.isLocked('my-lock')).toBe(true);
unlock1!();
expect(locks.isLocked('my-lock')).toBe(true);
expect(locks.isLocked('my-lock')).toBe(true);
expect(locks.isLocked('my-lock')).toBe(true);
unlock2!();
expect(locks.isLocked('my-lock')).toBe(false);
});
});

0 comments on commit 171797b

Please sign in to comment.