Firestore Backend โ
Distributed locking using Google Cloud Firestore as the backend. Ideal for applications already using Firestore or requiring serverless infrastructure.
CRITICAL: Never Delete Fence Counters
Fence counter documents in the fence_counters
collection MUST NEVER be deleted. Deleting fence counters breaks monotonicity guarantees and violates fencing safety. Cleanup operations MUST only target lock documents in the locks
collection, never fence counter documents.
See Fence Counter Lifecycle section for complete details.
Technical Specifications
For backend implementers: See specs/firestore-backend.md for complete implementation requirements, transaction patterns, and architecture decisions.
Installation โ
npm install syncguard @google-cloud/firestore
Quick Start โ
import { createLock } from "syncguard/firestore";
import { Firestore } from "@google-cloud/firestore";
const db = new Firestore();
const lock = createLock(db);
await lock(
async () => {
// Your critical section
await processPayment(paymentId);
},
{ key: `payment:${paymentId}`, ttlMs: 30000 },
);
Required Index โ
Critical Setup Step
Firestore backend requires a single-field index on the lockId
field for optimal performance.
Create the index before production use:
# Via Firebase Console
1. Navigate to Firestore > Indexes
2. Create single-field index: collection="locks", field="lockId", mode="Ascending"
# Via Terraform
resource "google_firestore_index" "lock_id" {
collection = "locks"
fields {
field_path = "lockId"
order = "ASCENDING"
}
}
Without this index, release()
and extend()
operations will be slow and may hit quota limits.
Configuration โ
Backend Options โ
import { createFirestoreBackend } from "syncguard/firestore";
const backend = createFirestoreBackend(db, {
collection: "app_locks", // Lock documents (default: "locks")
fenceCollection: "fence_counters", // Fence counters (default: "fence_counters")
cleanupInIsLocked: false, // Enable cleanup in isLocked (default: false)
});
Collections: Lock documents and fence counters use separate collections. Configure both to match your project structure:
const prefix = process.env.NODE_ENV === "production" ? "prod" : "dev";
const backend = createFirestoreBackend(db, {
collection: `${prefix}_locks`,
fenceCollection: `${prefix}_fence_counters`,
});
Cleanup in isLocked: When enabled, expired locks may be cleaned up during isLocked()
checks. Disabled by default to maintain pure read semantics.
Index Requirements
Create indexes for both collections if using custom names.
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)
});
Time Synchronization โ
Firestore uses client time for expiration checks. NTP synchronization is required in production environments.
Requirements โ
- Unified Tolerance: Fixed 1000ms tolerance (same as Redis) for consistent cross-backend behavior (ADR-005)
- NTP Sync (REQUIRED): Keep client clocks within ยฑ500ms accuracy for reliable operation
- Deployment Monitoring (REQUIRED): Implement NTP sync monitoring in deployment pipeline - fail deployments with >200ms drift
- Health Checks (REQUIRED): Add application-level health checks that detect and alert on clock skew
- Drift Monitoring: Alert on NTP drift exceeding ยฑ250ms
- Non-configurable: Tolerance is internal and cannot be changed to prevent semantic drift
Checking Time Sync โ
# Linux/macOS - check NTP status
timedatectl status
# Expected: "System clock synchronized: yes"
# Offset should be < 500ms
Production Requirement
If reliable time synchronization cannot be guaranteed within ยฑ500ms, use Redis backend instead.
Why Client Time? โ
Firestore doesn't provide server time queries. All expiration logic uses Date.now()
. This works reliably when:
- Servers are NTP-synchronized
- Combined clock drift stays under tolerance
- Operations complete within expected timeframes
Performance โ
Firestore backend provides solid performance for most distributed locking scenarios:
- Latency: 2-10ms per operation depending on region
- Throughput: 100-500 ops/sec per collection
- Transactions: All mutations use atomic transactions
Transaction Overhead โ
Each operation involves:
- Start transaction
- Read lock document(s)
- Verify expiration and ownership
- Write updates
- Commit transaction
Total latency: ~5-20ms including network round-trips.
Scaling Considerations โ
- Hot keys: Avoid >500 ops/sec on a single lock key
- Collection limits: Firestore handles 10k+ concurrent locks easily
- Document size: Lock documents are <1KB each
Firestore-Specific Features โ
Document Storage โ
Firestore backend uses two collections:
locks/{docId} โ Lock document (lockId, key, timestamps, fence)
fence_counters/{docId} โ Monotonic counter (persists indefinitely)
Document IDs are generated using the same key truncation as Redis (max 1500 bytes).
Atomic Transactions โ
All mutations execute atomically via runTransaction()
:
- Acquire: Read lock + counter โ verify expiration โ increment fence โ write both
- Release: Query by lockId โ verify ownership โ delete lock document
- Extend: Query by lockId โ verify ownership โ update expiration
Firestore guarantees no race conditions within transactions.
Fence Counter Lifecycle โ
CRITICAL: Fence counters are intentionally persistent and MUST NOT be deleted:
// โ NEVER do this - breaks monotonicity guarantee and fencing safety
await db.collection("fence_counters").doc(docId).delete(); // Violates fencing safety
Why This Is Critical:
- Monotonicity guarantee: Deleting counters breaks the strictly increasing fence token requirement
- Cross-backend consistency: Firestore must match Redis's fence counter persistence behavior
- Fencing safety: Counter reset would allow fence token reuse, violating safety guarantees
- Cleanup configuration: The
cleanupInIsLocked
option MUST NOT affect fence counter documents
Lifecycle Requirements:
- Lock documents are deleted on release or expiration
- Fence counters survive indefinitely (required for fencing safety)
- Cleanup operations never delete fence counters
- Both collections MUST be separate (enforced via config validation)
Configuration Safety: The backend validates that fenceCollection
differs from collection
to prevent accidental deletion. Attempting to use the same collection for both will throw LockError("InvalidArgument")
.
Dual Document Pattern
See specs/firestore-backend.md ยง Fencing Token Implementation for the complete dual-document pattern specification and atomic transaction requirements.
Common Patterns โ
Distributed 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 },
);
};
// Safe for multiple Cloud Functions to call simultaneously
Preventing Duplicate Webhooks โ
const handleWebhook = async (webhookId: string, payload: unknown) => {
await lock(
async () => {
const processed = await checkIfProcessed(webhookId);
if (!processed) {
await processWebhook(payload);
await markProcessed(webhookId);
}
},
{ key: `webhook:${webhookId}`, ttlMs: 60000 },
);
};
Scheduled Task Coordination โ
// Multiple Cloud Scheduler jobs, only one executes
export async function dailyReport(req: Request, res: Response) {
const today = new Date().toISOString().split("T")[0];
const acquired = await lock(
async () => {
await generateDailyReport();
return true;
},
{ key: `daily-report:${today}`, ttlMs: 3600000 }, // 1 hour
);
res.status(200).send({ executed: acquired });
}
Monitoring Lock Status โ
import { getByKey, getById, owns } from "syncguard";
// Check if a resource is currently locked
const info = await getByKey(backend, "resource:123");
if (info) {
console.log(`Resource locked until ${new Date(info.expiresAtMs)}`);
console.log(`Fence token: ${info.fence}`);
}
// Quick ownership check
if (await owns(backend, lockId)) {
console.log("Still own the lock");
}
// Detailed ownership info
const owned = await getById(backend, lockId);
if (owned) {
console.log(`Expires in ${owned.expiresAtMs - Date.now()}ms`);
}
Troubleshooting โ
Missing Index Error โ
If you see FAILED_PRECONDITION
or slow queries:
Error: The query requires an index. You can create it here:
https://console.firebase.google.com/project/.../firestore/indexes?create_composite=...
Solution: Create the required single-field index on lockId
(see Required Index section).
Permission Denied โ
Firestore requires read/write permissions on the lock collections:
// Firestore Security Rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /locks/{lockId} {
allow read, write: if request.auth != null;
}
match /fence_counters/{lockId} {
allow read, write: if request.auth != null;
}
}
}
Adjust rules based on your authentication strategy.
Time Skew Issues โ
If locks expire unexpectedly or stay locked too long:
# Check NTP synchronization
timedatectl status
# On Docker/K8s, ensure NTP is available in containers
# Add to Dockerfile:
RUN apt-get update && apt-get install -y ntpdate
Symptoms of time skew:
- Locks expire immediately after acquisition
- Locks never expire despite TTL passing
extend()
operations fail with "expired" errors
Solution: Verify all servers have NTP sync within ยฑ500ms.
Transaction Conflicts โ
High contention on the same key may cause ABORTED
transaction errors:
// SyncGuard automatically retries ABORTED transactions
// If you see frequent conflicts, reduce concurrency:
await lock(workFn, {
key: "resource:123",
maxRetries: 20, // Increase retries
retryDelayMs: 200, // Increase delay
timeoutMs: 10000, // Increase timeout
});
Document Size Limits โ
Firestore document IDs have a 1500-byte limit. SyncGuard automatically truncates long keys:
// Long keys are automatically truncated using hash-based truncation
const result = await backend.acquire({
key: "x".repeat(2000), // Automatically truncated to fit 1500-byte limit
ttlMs: 30000,
});
User-supplied keys are capped at 512 bytes after normalization.
Cost Optimization
Firestore charges per document operation. For high-throughput scenarios (>1000 locks/sec), consider Redis backend for lower operational costs.