Skip to content

TypeScript distributed lock library that prevents race conditions across services. Because nobody wants their payment processed twice! 💸

License

Notifications You must be signed in to change notification settings

kriasoft/syncguard

Repository files navigation

SyncGuard

TypeScript distributed lock library that prevents race conditions across services. Because nobody wants their payment processed twice! 💸

Supports Firestore and Redis backends with automatic cleanup and bulletproof concurrency control.

Installation

npm install syncguard @google-cloud/firestore
# or with Redis (for the speed demons 🏎️)
npm install syncguard ioredis

Usage

Basic Example - Preventing Race Conditions

import { createLock } from "syncguard/firestore";
import { Firestore } from "@google-cloud/firestore";

const db = new Firestore();
const lock = createLock(db);

// Prevent duplicate payment processing
await lock(
  async () => {
    const payment = await getPayment(paymentId);
    if (payment.status === "pending") {
      await processPayment(payment);
      await updatePaymentStatus(paymentId, "completed");
    }
  },
  { key: `payment:${paymentId}`, ttlMs: 60000 },
);

Manual Lock Control

// For long-running operations that need more control
const result = await lock.acquire({
  key: "batch:daily-report",
  ttlMs: 300000, // 5 minutes
  timeoutMs: 10000, // Wait up to 10s to acquire
});

if (result.success) {
  try {
    await generateDailyReport();

    // Extend lock if needed (critical: handle failures!)
    const extended = await lock.extend(result.lockId, 300000);
    if (!extended) {
      throw new Error(
        "Failed to extend lock - aborting to prevent race conditions",
      );
    }

    await sendReportEmail();
  } finally {
    await lock.release(result.lockId);
  }
} else {
  console.error("Could not acquire lock:", result.error);
}

Multiple Backends

// Firestore
import { createLock } from "syncguard/firestore";
const firestoreLock = createLock(new Firestore());

// Redis
import { createLock } from "syncguard/redis";
const redisLock = createLock(redisClient);

// Custom backend
import { createLock } from "syncguard";
const customLock = createLock(myBackend);

Configuration

All the knobs and dials you need to tune your locks to perfection:

interface LockConfig {
  key: string; // Unique lock identifier
  ttlMs?: number; // Lock duration (default: 30s)
  timeoutMs?: number; // Max wait time to acquire (default: 5s)
  maxRetries?: number; // Retry attempts (default: 10)
  retryDelayMs?: number; // Delay between retries (default: 100ms)
}

Firestore Backend Options

const lock = createLock(db, {
  collection: "app_locks", // Custom collection name (default: "locks")
  retryDelayMs: 200, // Override retry delay
  maxRetries: 15, // More aggressive retries
});

⚠️ Important: Firestore backend requires an index on the lockId field for optimal performance. Without it, your locks will be slower than a sleepy sloth! 🦥

Error Handling

When things go sideways (and they will), handle it gracefully:

import { LockError } from "syncguard";

try {
  await lock(
    async () => {
      // Your critical section here
    },
    { key: "resource:123" },
  );
} catch (error) {
  if (error instanceof LockError) {
    console.error("Lock operation failed:", error.code, error.message);
    // Handle specific error types: ACQUISITION_FAILED, TIMEOUT, etc.
  }
}

Common Patterns

Preventing Duplicate Job Processing

"I said do it once, not twice!" - Every developer ever

const processJob = async (jobId: string) => {
  await lock(
    async () => {
      const job = await getJob(jobId);
      if (job.status === "pending") {
        await executeJob(job);
        await markJobComplete(jobId);
      }
      // If job was already processed, this is a no-op (which is perfect!)
    },
    { key: `job:${jobId}`, ttlMs: 300000 }, // 5 minute timeout
  );
};

Rate Limiting

Because some users think your API is a free-for-all

const checkRateLimit = async (userId: string) => {
  const result = await lock.acquire({
    key: `rate:${userId}`,
    ttlMs: 60000, // 1 minute window
    timeoutMs: 0, // Fail immediately if locked
    maxRetries: 0, // No retries for rate limiting
  });

  if (!result.success) {
    throw new Error("Rate limit exceeded. Slow down there, speed racer! 🏁");
  }

  // Don't release - let it expire naturally for rate limiting
  return performOperation(userId);
};

Database Migration Lock

Single-file migrations only, please

const runMigration = async (version: string) => {
  await lock(
    async () => {
      const currentVersion = await getCurrentDbVersion();
      if (currentVersion < version) {
        console.log(`Running migration to version ${version}...`);
        await runMigrationScripts(version);
        await updateDbVersion(version);
      } else {
        console.log("Migration already applied, skipping");
      }
    },
    { key: "db:migration", ttlMs: 600000 }, // 10 minutes for safety
  );
};

Custom Backends

Implement the LockBackend interface for custom storage:

import { LockBackend, createLock } from "syncguard";

const myBackend: LockBackend = {
  async acquire(config) {
    /* your implementation */
  },
  async release(lockId) {
    /* your implementation */
  },
  async extend(lockId, ttl) {
    /* your implementation */
  },
  async isLocked(key) {
    /* your implementation */
  },
};

const lock = createLock(myBackend);

Support

Got questions? Hit a snag? Or just want to share your awesome WebSocket creation? Find us on Discord. We promise we don't bite (usually 😉).

Backers

              

License

This project is licensed under the MIT License. See the LICENSE file for details.