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:
2026-10-12 10:57:17 +03:00
parent 1fe2421cc4
commit ee5bb5beb8
6 changed files with 277 additions and 54 deletions
+25
View File
@@ -0,0 +1,25 @@
import { Schema, model, models, Model } from "mongoose";
export interface IStockLock {
_id: string; // `${businessId}:${branchId}:${productId}`
owner: string; // random id of the request holding the lock
lockedUntil: Date; // lease expiry: a crashed request cannot hold the lock forever
}
/**
* A short lease on one product's stock at one branch, held while a request
* reads the current balance and writes a movement that depends on it (see
* withStockLock in src/lib/server/stock.ts). Rows are deleted on release;
* an expired row is simply taken over by the next request.
*/
const stockLockSchema = new Schema<IStockLock>(
{
_id: { type: String, required: true },
owner: { type: String, required: true },
lockedUntil: { type: Date, required: true },
},
{ versionKey: false }
);
export const StockLock =
(models.StockLock as Model<IStockLock>) || model<IStockLock>("StockLock", stockLockSchema);