Stop simultaneous transfers from overdrawing a branch
A transfer read the sending branch's stock and then wrote its movements as two separate steps. Requests arriving together all saw the same balance and all passed the check: five simultaneous transfers of 4 from a stock of 10 all succeeded, sending 20. The check and the write now run under withStockLock: a short lease on one product's stock at one branch, taken with a single atomic upsert (it only succeeds when no unexpired lease exists) and released when the request finishes. A second request waits briefly, then sees the updated balance; after 5 seconds it gets 409 "try again". A lease expires after 10 seconds, so a crashed request cannot block the stock for good. Physical counts take the same lock, so a count is never computed from a balance a transfer is changing. A double-tapped transfer that races itself still moves stock once. Sales do not take the lock: they are recorded even when stock runs out, by design. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { StockMovement, type MovementType } from "@/lib/server/models/StockMovem
|
||||
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
import { Purchase } from "@/lib/server/models/Purchase";
|
||||
import { StockLock } from "@/lib/server/models/StockLock";
|
||||
import type { ITransaction } from "@/lib/server/models/Transaction";
|
||||
|
||||
type Id = Types.ObjectId | string;
|
||||
@@ -173,3 +174,58 @@ export async function backfillPurchaseMovements(businessId?: Id): Promise<number
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
const LOCK_LEASE_MS = 10_000;
|
||||
const LOCK_WAIT_MS = 5_000;
|
||||
|
||||
/** Another request kept the stock busy for longer than we are willing to wait. */
|
||||
export class StockBusyError extends Error {
|
||||
constructor() {
|
||||
super("This stock is being updated by someone else. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` while holding the lock on one product's stock at one branch.
|
||||
*
|
||||
* Anything that reads the balance and then writes a movement based on it
|
||||
* (a transfer refusing to overdraw, a physical count computing its
|
||||
* correction) must run inside this. Without it, two transfers that each
|
||||
* see 10 in stock can both send 6, because the read and the write are
|
||||
* separate operations. With it, the second waits for the first and then
|
||||
* sees 4.
|
||||
*
|
||||
* The lock is a lease row: acquired with one atomic upsert that only
|
||||
* succeeds if no unexpired lease exists (otherwise the insert hits the
|
||||
* unique _id and fails), released by deleting it. Sales do not take the
|
||||
* lock - they are recorded even when stock runs out (see PILOT_READINESS).
|
||||
*/
|
||||
export async function withStockLock<T>(businessId: Id, branchId: Id, productId: Id, fn: () => Promise<T>): Promise<T> {
|
||||
const key = `${businessId}:${branchId}:${productId}`;
|
||||
const owner = crypto.randomUUID();
|
||||
const deadline = Date.now() + LOCK_WAIT_MS;
|
||||
let delay = 10;
|
||||
|
||||
for (;;) {
|
||||
const now = Date.now();
|
||||
try {
|
||||
await StockLock.updateOne(
|
||||
{ _id: key, lockedUntil: { $lt: new Date(now) } },
|
||||
{ $set: { owner, lockedUntil: new Date(now + LOCK_LEASE_MS) } },
|
||||
{ upsert: true }
|
||||
);
|
||||
break;
|
||||
} catch (err: unknown) {
|
||||
if ((err as { code?: number })?.code !== 11000) throw err;
|
||||
}
|
||||
if (Date.now() > deadline) throw new StockBusyError();
|
||||
await new Promise((r) => setTimeout(r, delay + Math.random() * delay));
|
||||
delay = Math.min(delay * 2, 200);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await StockLock.deleteOne({ _id: key, owner });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user