import type { Method } from "@prisma/client"; import redis from "../lib/redis.server"; // Soft slot-holds: when a shopper picks a slot, we reserve one unit of its // capacity for a short TTL so a second shopper can't grab the same last // unit while the first is still filling out checkout (PRODUCT_STRATEGY.md // §3.1 — "the last-slot race condition"). Redis is the sole source of // truth for holds (see the schema.prisma comment on why there's no // matching Postgres table); its native key expiry is exactly the semantics // a time-limited reservation needs. // // Each slot's outstanding holds live in one Redis sorted set, scored by // expiry timestamp (ms). Creating a hold has to be atomic — read the // current count, compare to the capacity budget, and add the new member — // or two concurrent requests can both read "one spot left" and both // succeed. That's done with a Lua script (EVAL), which Redis runs // single-threaded and atomically; there is no equivalent way to get this // right with separate round-trips from Node. export const DEFAULT_HOLD_TTL_MS = 10 * 60 * 1000; // 10 minutes const TRY_CREATE_HOLD_SCRIPT = ` local key = KEYS[1] local now = tonumber(ARGV[1]) local expiresAt = tonumber(ARGV[2]) local member = ARGV[3] local capacity = tonumber(ARGV[4]) redis.call('ZREMRANGEBYSCORE', key, '-inf', now) -- The same cart re-confirming its own already-held slot (a retried -- request, a back/forward navigation, a duplicate click) is a renewal, not -- a new claim — it must not have to compete against the capacity gate a -- second time, or a shopper can lock themselves out of the slot they -- already hold. if redis.call('ZSCORE', key, member) then redis.call('ZADD', key, expiresAt, member) return 1 end local count = redis.call('ZCARD', key) if count < capacity then redis.call('ZADD', key, expiresAt, member) redis.call('PEXPIRE', key, 3600000) return 1 else return 0 end `; redis.defineCommand("tryCreateHoldScript", { numberOfKeys: 1, lua: TRY_CREATE_HOLD_SCRIPT, }); // ioredis's defineCommand() adds the method at runtime without changing the // client's static type, so this augments it for the one command we added. declare module "ioredis" { // eslint-disable-next-line @typescript-eslint/no-unused-vars -- must match ioredis's own generic arity to merge interface RedisCommander { tryCreateHoldScript( key: string, now: number, expiresAt: number, member: string, capacity: number, ): Promise; } } export interface SlotIdentity { shopDomain: string; locationId: string; method: Method; /** The slot's exact start instant, as an ISO string (stable, timezone-unambiguous cache key). */ slotStartIso: string; } function holdSetKey({ shopDomain, locationId, method, slotStartIso }: SlotIdentity): string { return `hold:${shopDomain}:${locationId}:${method}:${slotStartIso}`; } export interface TryCreateHoldResult { success: boolean; expiresAt: number; } /** * Attempts to reserve one unit of capacity for `cartToken` on the given * slot. `capacityBudget` is the capacity still available to holds — i.e. * template capacity minus already-confirmed Bookings, computed by the * caller from Postgres immediately before calling this. Returns * success: false if the slot's outstanding holds already meet that budget. */ export async function tryCreateHold( slot: SlotIdentity, cartToken: string, capacityBudget: number, ttlMs: number = DEFAULT_HOLD_TTL_MS, ): Promise { const now = Date.now(); const expiresAt = now + ttlMs; const result = await redis.tryCreateHoldScript(holdSetKey(slot), now, expiresAt, cartToken, capacityBudget); return { success: result === 1, expiresAt }; } export async function releaseHold(slot: SlotIdentity, cartToken: string): Promise { await redis.zrem(holdSetKey(slot), cartToken); } /** Active (non-expired) hold count for a slot — evicts expired members first. */ export async function countActiveHolds(slot: SlotIdentity): Promise { const key = holdSetKey(slot); await redis.zremrangebyscore(key, "-inf", Date.now()); return redis.zcard(key); }