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

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

11 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

SyncGuard

npm version npm downloads MIT License TypeScript Discord

TypeScript distributed lock library that prevents race conditions across services. Supports Redis and Firestore backends with automatic cleanup, fencing tokens, and bulletproof concurrency control.

Installation

# Firestore backend
npm install syncguard @google-cloud/firestore

# Redis backend
npm install syncguard ioredis

Usage

Quick Start

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 },
);

Using Redis

import { createLock } from "syncguard/redis";
import Redis from "ioredis";

const redis = new Redis();
const lock = createLock(redis);

await lock(
  async () => {
    // Your critical section
  },
  { key: "resource:123" },
);

Manual Lock Control

const backend = createRedisBackend(redis);

// Acquire lock manually
const result = await backend.acquire({
  key: "batch:daily-report",
  ttlMs: 300000, // 5 minutes
});

if (result.ok) {
  try {
    const { lockId, fence } = result; // Fencing token for stale lock protection
    await generateDailyReport(fence);

    // Extend lock for long-running tasks
    const extended = await backend.extend({ lockId, ttlMs: 300000 });
    if (!extended.ok) {
      throw new Error("Failed to extend lock");
    }

    await sendReportEmail();
  } finally {
    await backend.release({ lockId: result.lockId });
  }
} else {
  console.log("Resource is locked by another process");
}

Ownership Checking

import { owns, getByKey } from "syncguard";

// Check if you still own the lock
const stillOwned = await owns(backend, lockId);

// Get lock info by resource key
const info = await getByKey(backend, "resource:123");
if (info) {
  console.log(`Lock expires in ${info.expiresAtMs - Date.now()}ms`);
}

Configuration

// Basic lock options
await lock(workFn, {
  key: "resource:123", // Required: unique identifier
  ttlMs: 30000, // Lock duration (default: 30s)
  timeoutMs: 5000, // Max acquisition wait (default: 5s)
  maxRetries: 10, // Retry attempts (default: 10)
});

Backend Configuration

// Firestore
const lock = createLock(db, {
  collection: "app_locks", // Default: "locks"
  fenceCollection: "app_fences", // Default: "fence_counters"
});

// Redis
const lock = createLock(redis, {
  keyPrefix: "myapp", // Default: "syncguard"
});

::: warning Firestore Index Required Firestore requires a single-field ascending index on the lockId field in your locks collection. See Firestore setup docs for details. :::

Error Handling

import { LockError } from "syncguard";

try {
  await lock(
    async () => {
      // Critical section
    },
    { key: "resource:123" },
  );
} catch (error) {
  if (error instanceof LockError) {
    console.error(`Lock error [${error.code}]:`, error.message);
    // Error codes: AcquisitionTimeout, ServiceUnavailable, NetworkTimeout, etc.
  }
}

Common Patterns

Preventing Duplicate Job Processing

const processJob = async (jobId: string) => {
  await lock(
    async () => {
      const job = await getJob(jobId);
      if (job.status === "pending") {
        await executeJob(job);
        await markJobComplete(jobId);
      }
    },
    { key: `job:${jobId}`, ttlMs: 300000 },
  );
};

Rate Limiting

const backend = createRedisBackend(redis);

const checkRateLimit = async (userId: string) => {
  const result = await backend.acquire({
    key: `rate:${userId}`,
    ttlMs: 60000, // 1 minute window
  });

  if (!result.ok) {
    throw new Error("Rate limit exceeded");
  }

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

Features

  • πŸ”’ Bulletproof concurrency - Atomic operations prevent race conditions
  • πŸ›‘οΈ Fencing tokens - Monotonic counters protect against stale writes
  • 🧹 Automatic cleanup - TTL-based expiration, no manual cleanup needed
  • πŸ”„ Backend flexibility - Redis (fast) or Firestore (serverless)
  • πŸ” Smart retries - Exponential backoff with jitter handles contention
  • πŸ’™ TypeScript-first - Full type safety with compile-time guarantees
  • πŸ“Š Optional telemetry - Opt-in observability via decorator pattern

Contributing

We welcome contributions! Here's how you can help:

  • πŸ› Bug fixes - Include test cases
  • πŸš€ New backends - Follow specs/interface.md
  • πŸ“– Documentation - Examples, guides, troubleshooting
  • πŸ“‹ Spec reviews - Validate specs match implementation, propose improvements
  • βœ… Tests - Improve coverage

See CONTRIBUTING.md for detailed guidelines.

Support & Documentation

Backers

Β Β Β Β Β Β Β Β Β Β Β Β Β Β 

License

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

Sponsor this project

 

Contributors 2

  •  
  •