ee5bb5beb8
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>
26 lines
932 B
TypeScript
26 lines
932 B
TypeScript
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);
|