Module A-5·11 min read

Reentrant locking with Hash-stored reentry counters, lock hierarchies and consistent ordering to prevent circular waits, the watchdog pattern for automatic lock extension, and distinguishing mutex locks from counting semaphores.

Written by Jatin Jain Saraf · Architect of SupraScan

A-5 — Reentrant Locks, Hierarchies, and Deadlock Prevention

Who this module is for: You have basic Redis locking working but are hitting edge cases — a function that holds a lock tries to call another function that also acquires the same lock (deadlock), or you need to acquire multiple locks without racing with another process doing the same. This module covers reentrant locks, lock hierarchies, and the patterns that prevent deadlock at scale.


The Reentrant Lock Problem

A basic Redis lock (SET NX PX) is not reentrant. If a function holds lock:resource and calls a subroutine that also tries to acquire lock:resource, the subroutine is waiting for itself to release a lock it holds — it can never succeed while the outer call is still running.

processOrder(orderId)
  → acquireLock('order:1001')     ✓ acquired
  → validateInventory(orderId)
      → acquireLock('order:1001') ✗ can never succeed (deadlock)

Using A-1's acquireLock() (bounded retries, returns null on exhaustion) as written, the inner call doesn't hang forever — it retries a few times and returns null, so validateInventory has to handle a null lock result it was never designed for, typically by throwing. Either way, processOrder never reaches the code path that releases the outer lock: not because the single-threaded event loop is blocked (an await suspends only the current async function and lets the event loop keep running everything else), but because the outer function's own continuation never gets past the failed inner acquireLock() call. It's a logical deadlock in your application's control flow, not a stalled event loop.


Reentrant Lock Implementation

A reentrant lock tracks the holder's identity and a reentry count. The same holder can acquire the lock multiple times; it is released only when the reentry count reaches zero.

Use a Redis Hash to store both the holder token and the count:

-- KEYS[1] = lock key
-- ARGV[1] = holder UUID
-- ARGV[2] = TTL in milliseconds
 
-- Acquire or reenter
local current_holder = redis.call('HGET', KEYS[1], 'holder')
if current_holder == false then
  -- Lock is free: acquire it
  redis.call('HSET', KEYS[1], 'holder', ARGV[1], 'count', 1)
  redis.call('PEXPIRE', KEYS[1], ARGV[2])
  return 1  -- acquired
elseif current_holder == ARGV[1] then
  -- Same holder: reenter
  redis.call('HINCRBY', KEYS[1], 'count', 1)
  redis.call('PEXPIRE', KEYS[1], ARGV[2])  -- reset TTL
  return redis.call('HGET', KEYS[1], 'count')  -- return current count
else
  -- Different holder: cannot acquire
  return 0
end
-- KEYS[1] = lock key
-- ARGV[1] = holder UUID
 
-- Release (decrement count; delete when count reaches 0)
local current_holder = redis.call('HGET', KEYS[1], 'holder')
if current_holder ~= ARGV[1] then
  return -1  -- not the holder; error
end
local count = redis.call('HINCRBY', KEYS[1], 'count', -1)
if count <= 0 then
  redis.call('DEL', KEYS[1])
  return 0  -- fully released
end
return count  -- still held (reentry count > 0)
const REENTRANT_ACQUIRE_SHA = await redis.script('LOAD', reentrantAcquireScript);
const REENTRANT_RELEASE_SHA = await redis.script('LOAD', reentrantReleaseScript);
 
// In Node.js (single-threaded): use a per-async-context token stored in AsyncLocalStorage
import { AsyncLocalStorage } from 'async_hooks';
 
const lockStore = new AsyncLocalStorage<{ token: string; locks: Set<string> }>();
 
async function acquireReentrantLock(resource: string, ttlMs: number): Promise<boolean> {
  const context = lockStore.getStore();
  if (!context) throw new Error('Must run inside lockStore.run()');
 
  const result = await redis.evalsha(
    REENTRANT_ACQUIRE_SHA, 1,
    `lock:${resource}`,
    context.token,
    String(ttlMs)
  ) as number;
 
  if (result >= 1) {
    context.locks.add(resource);
    return true;
  }
  return false;
}
 
async function releaseReentrantLock(resource: string): Promise<void> {
  const context = lockStore.getStore();
  if (!context) throw new Error('Must run inside lockStore.run()');
 
  const result = await redis.evalsha(
    REENTRANT_RELEASE_SHA, 1,
    `lock:${resource}`,
    context.token
  ) as number;
 
  if (result === 0) {
    context.locks.delete(resource);
  }
}
 
// Usage: wrap the entire request/job in lockStore.run()
async function handleRequest(requestId: string) {
  const token = randomUUID();
  await lockStore.run({ token, locks: new Set() }, async () => {
    await processOrder('1001');  // can call acquireReentrantLock('order:1001') inside
  });
}

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.