Module A-1·12 min read

SET key value NX PX as the atomic lock primitive, UUID lock values to prevent accidental release, lock extension with conditional PEXPIRE, the critical GC-pause failure mode, and why distributed locks need fencing tokens.

Written by Jatin Jain Saraf · Architect of SupraScan

A-1 — Single-Instance Locking: SET NX PX and Lock Correctness

Who this module is for: You need to ensure only one process at a time executes a critical section — a payment deduction, a job processing step, a cache recompute. This module covers the correct Redis single-instance lock primitive, the mistakes that make naive implementations unsafe, and the fundamental limitation that requires fencing tokens for true correctness.


The Lock Primitive: SET NX PX

A Redis distributed lock uses a single key with three properties:

  1. Existence — the key exists means the lock is held
  2. Identity — the key's value identifies the lock holder (prevents accidental release)
  3. Expiry — the key has a TTL so it auto-releases if the holder crashes

The atomic primitive that satisfies all three in a single command:

SET lock:resource "lock-value" NX PX 30000
  • NX — only set if the key does Not eXist (acquire only if nobody holds the lock)
  • PX 30000 — expire in 30,000 milliseconds (auto-release if holder crashes)

Returns OK if the lock was acquired, nil if already held by another client.

Why this must be a single command: A non-atomic acquire would be:

# Wrong (race condition):
EXISTS lock:resource         → 0 (not held)
SET lock:resource "holder"   → sets the lock
EXPIRE lock:resource 30000   → sets expiry

Between EXISTS and SET, another client can acquire the lock. Between SET and EXPIRE, a crash leaves a permanent lock. The SET key value NX PX single command eliminates both races.


Lock Value: UUID for Identity

The lock value must uniquely identify the holder. Use a cryptographically random UUID:

import { randomUUID } from 'crypto';
 
const lockValue = randomUUID();  // e.g., "f47ac10b-58cc-4372-a567-0e02b2c3d479"
await redis.set('lock:payment:1001', lockValue, 'NX', 'PX', 30000);

Why identity matters: Without a unique value, any client can release any lock:

# Wrong (no identity):
Client A: SET lock:payment ""  → acquires lock
# ... lock expires while A is slow ...
Client B: SET lock:payment ""  → acquires lock (A's lock expired)
Client A: DEL lock:payment     → releases B's lock! A doesn't know it lost the lock.

With a UUID, releasing the lock requires presenting the same UUID that was set:

// Safe release via Lua (atomic check-and-delete)
const releaseScript = `
  if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
  else
    return 0
  end
`;
 
async function releaseLock(key: string, lockValue: string): Promise<boolean> {
  const result = await redis.eval(releaseScript, 1, key, lockValue);
  return result === 1;
}

The Lua script atomically checks that the current lock value matches before deleting — if the lock expired and was acquired by another client, the check fails and we do not release their lock.


Full Lock Implementation

import { randomUUID } from 'crypto';
import Redis from 'ioredis';
 
const redis = new Redis();
 
const RELEASE_SCRIPT = `
  if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
  else
    return 0
  end
`;
 
interface Lock {
  release: () => Promise<boolean>;
  extend: (ttlMs: number) => Promise<boolean>;
}
 
async function acquireLock(
  resource: string,
  ttlMs: number,
  retries = 3,
  retryDelayMs = 100
): Promise<Lock | null> {
  const key = `lock:${resource}`;
  const value = randomUUID();
 
  for (let attempt = 0; attempt <= retries; attempt++) {
    const result = await redis.set(key, value, 'NX', 'PX', ttlMs);
 
    if (result === 'OK') {
      return {
        release: () => releaseLock(key, value),
        extend: (newTtlMs) => extendLock(key, value, newTtlMs),
      };
    }
 
    if (attempt < retries) {
      await new Promise(r => setTimeout(r, retryDelayMs + Math.random() * retryDelayMs));
    }
  }
 
  return null;  // Could not acquire after all retries
}
 
async function releaseLock(key: string, value: string): Promise<boolean> {
  const result = await redis.eval(RELEASE_SCRIPT, 1, key, value);
  return result === 1;
}
 
async function extendLock(key: string, value: string, newTtlMs: number): Promise<boolean> {
  const extendScript = `
    if redis.call("GET", KEYS[1]) == ARGV[1] then
      return redis.call("PEXPIRE", KEYS[1], ARGV[2])
    else
      return 0
    end
  `;
  const result = await redis.eval(extendScript, 1, key, value, String(newTtlMs));
  return result === 1;
}
 
// Usage:
async function processPayment(paymentId: string) {
  const lock = await acquireLock(`payment:${paymentId}`, 30000);
 
  if (!lock) {
    throw new Error('Could not acquire lock — payment already in progress');
  }
 
  try {
    await executePaymentLogic(paymentId);
  } finally {
    await lock.release();
  }
}

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.