Files
edr-platform/apps/edr-passenger-api/scripts/stress-booking-concurrency.cjs

360 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* Booking concurrency + regression harness.
*
* Companion to repro-double-booking.cjs: that one proves specific attacks are closed, this
* one proves the system stays correct under load and that legitimate bookings are not
* rejected as collateral.
*
* node scripts/stress-booking-concurrency.cjs
* --schedule <uuid> --origin <uuid> --destination <uuid>
* [--mode ladder|edge|all] [--levels 2,5,10,25,50,100]
* [--return-schedule <uuid> --return-origin <uuid> --return-destination <uuid>]
*
* ladder, per concurrency level N:
* hold-contest N browsers click one seat at once -> exactly 1 hold granted
* booking-storm N submits of one valid hold at once -> exactly 1 booking created
* diff-seats N users book N different seats at once -> all N succeed (no false rejects)
*
* edge: multi-passenger, all-or-nothing partial availability, overlapping multi-seat
* requests, and a contested round trip.
*
* Everything goes through the public HTTP API — no direct database writes. Every request
* body is built up front and released together from one Promise.all, and each line reports
* an factor (summed latency / wall time) so the concurrency is provable rather
* than assumed.
*
* Bookings land in PENDING_PAYMENT and are never paid. Every ref created is printed at the
* end; cancel them afterwards. Pick a FUTURE departure with plenty of free seats.
*/
const BASE = (process.env.API_URL || 'http://localhost:3002').replace(/\/$/, '');
const argv = process.argv.slice(2);
const arg = (n, d) => { const i = argv.indexOf(`--${n}`); return i !== -1 && argv[i + 1] ? argv[i + 1] : d; };
const SCHED = arg('schedule');
const ORIGIN = arg('origin');
const DEST = arg('destination');
const RET_SCHED = arg('return-schedule');
const RET_ORIGIN = arg('return-origin');
const RET_DEST = arg('return-destination');
const MODE = arg('mode', 'ladder');
const LEVELS = arg('levels', '2,5,10,25,50,100').split(',').map(Number);
// Node's built-in fetch (undici) defaults to an unlimited per-origin connection pool, so
// Promise.all really does put every request on the wire at once. Proven per run by
// comparing wall time against the summed per-request latency (see in each line).
const RUN = Date.now().toString(36).slice(-5).toUpperCase();
let pc = 0;
const uuid = () => crypto.randomUUID();
async function api(method, path, body) {
const t0 = Date.now();
try {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = { raw: text.slice(0, 200) }; }
const data = json && typeof json === 'object' && 'success' in json && 'data' in json ? json.data : json;
return { ok: res.ok, status: res.status, json, data, ms: Date.now() - t0 };
} catch (e) {
return { ok: false, status: 0, json: { message: `NETWORK: ${e.message}` }, data: null, ms: Date.now() - t0 };
}
}
const msg = (r) => String(r.json?.message ?? r.json?.error ?? JSON.stringify(r.json ?? {})).slice(0, 150);
function person(seatId) {
pc++;
const tag = `${RUN}${String(pc).padStart(4, '0')}`;
return {
seatId,
passengerName: `Stress ${tag}`,
dateOfBirth: '1990-05-15',
idDocumentType: 'PASSPORT',
passportNumber: `ST${tag}`,
passportCountry: 'Djibouti',
nationality: 'Other',
phone: `+2519${String(40000000 + pc).slice(0, 8)}`,
};
}
const holdBody = (seatIds, sched = SCHED, o = ORIGIN, d = DEST, dir, pid) => ({
scheduleId: sched, originStationId: o, destinationStationId: d,
...(dir ? { journeyDirection: dir } : {}),
passengers: seatIds.map((seatId) => ({ passengerId: pid ?? uuid(), seatId })),
});
async function hold(seatIds, sched, o, d, dir, pid) {
const r = await api('POST', '/seats/hold', holdBody(seatIds, sched, o, d, dir, pid));
return { ...r, holdId: r.data?.holdId ?? r.data?.id };
}
function oneWayBody(holdId, seatIds, seatClassId) {
return {
bookingType: 'ONE_WAY', scheduleId: SCHED, holdId,
originStationId: ORIGIN, destinationStationId: DEST,
seatClassId, skipIdentityVerification: true,
passengers: seatIds.map((s) => person(s)),
};
}
function roundTripBody(holdId, returnHoldId, outSeats, retSeats, seatClassId) {
return {
bookingType: 'ROUND_TRIP', scheduleId: SCHED, holdId,
originStationId: ORIGIN, destinationStationId: DEST,
returnScheduleId: RET_SCHED, returnHoldId,
returnOriginStationId: RET_ORIGIN, returnDestinationStationId: RET_DEST,
seatClassId, returnSeatClassId: seatClassId, skipIdentityVerification: true,
passengers: outSeats.map((s, i) => ({ ...person(s), returnSeatId: retSeats[i] })),
};
}
async function book(body) {
const r = await api('POST', '/bookings/guest', body);
return { ...r, bookingRef: r.data?.bookingRef };
}
async function seatPool(sched = SCHED, o = ORIGIN, d = DEST) {
const r = await api('GET', `/seats/seatmap/${sched}?originStationId=${o}&destinationStationId=${d}`);
if (!r.ok) throw new Error(`seatmap ${r.status}: ${msg(r)}`);
const out = [];
for (const c of r.data?.coaches ?? [])
for (const s of c.seats ?? [])
if (String(s.status).toUpperCase() === 'AVAILABLE')
out.push({ id: s.id, seatNumber: s.seatNumber, coach: c.coachNumber, coachClass: c.seatClass });
return out;
}
async function seatClassId(seat) {
const r = await api('GET', '/seat-classes');
const list = Array.isArray(r.data) ? r.data : (r.data?.items ?? []);
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
return (list.find((c) => norm(c.name) === norm(seat.coachClass)) ?? list[0]).id;
}
/** Fire everything at once and report the arrival spread so "concurrent" is provable. */
async function fireAll(tasks) {
const t0 = Date.now();
const starts = [];
const wrapped = tasks.map((fn) => (async () => { starts.push(Date.now() - t0); return fn(); })());
const results = await Promise.all(wrapped);
const wallMs = Date.now() - t0;
const sumMs = results.reduce((a, r) => a + (r.ms || 0), 0);
// overlap >> 1 proves the requests were genuinely in flight together rather than queued.
return { results, wallMs, releaseSpreadMs: Math.max(...starts) - Math.min(...starts), overlap: (sumMs / Math.max(wallMs, 1)).toFixed(1) };
}
function tally(results) {
const t = {};
for (const r of results) {
const k = r.bookingRef ? 'created' : r.holdId ? 'held' : `http_${r.status}`;
t[k] = (t[k] || 0) + 1;
}
return t;
}
const errSample = (results) =>
[...new Set(results.filter((r) => !r.bookingRef && !r.holdId).map((r) => `${r.status}: ${msg(r)}`))].slice(0, 3);
const ALL = { created: [], failures: [] };
function note(refs) { ALL.created.push(...refs); }
// ─────────────────────────────────────────────────────────────────────────────
/** N users all click the SAME seat: N simultaneous holds. Exactly 1 must win. */
async function holdContest(pool, n) {
const seat = pool.shift();
const { results, wallMs, releaseSpreadMs, overlap } = await fireAll(
Array.from({ length: n }, () => () => hold([seat.id])),
);
const won = results.filter((r) => r.holdId);
return {
name: `hold-contest n=${n}`,
pass: won.length === 1,
detail: `seat ${seat.seatNumber}: ${won.length} hold(s) granted ${JSON.stringify(tally(results))} ` +
`wall=${wallMs}ms release-spread=${releaseSpreadMs}ms overlap=x${overlap}`,
errs: errSample(results),
winner: won[0],
seat,
};
}
/** The double-submit / retry storm: N simultaneous bookings on ONE valid hold. */
async function bookingStorm(seat, holdId, scid, n) {
const bodies = Array.from({ length: n }, () => oneWayBody(holdId, [seat.id], scid));
const { results, wallMs, releaseSpreadMs, overlap } = await fireAll(bodies.map((b) => () => book(b)));
const won = results.filter((r) => r.bookingRef);
note(won.map((r) => r.bookingRef));
return {
name: `booking-storm n=${n}`,
pass: won.length === 1,
detail: `seat ${seat.seatNumber}: ${won.length} booking(s) ${JSON.stringify(tally(results))} ` +
`wall=${wallMs}ms release-spread=${releaseSpreadMs}ms refs=${won.map((r) => r.bookingRef).join(',')}`,
errs: errSample(results),
};
}
/** N users, N DIFFERENT seats, all at once. All N must succeed — no false rejections. */
async function differentSeats(pool, scid, n) {
const seats = pool.splice(0, n);
if (seats.length < n) return { name: `diff-seats n=${n}`, pass: false, detail: 'not enough free seats', errs: [] };
const holds = await Promise.all(seats.map((s) => hold([s.id])));
const usable = holds.map((h, i) => ({ h, s: seats[i] })).filter((x) => x.h.holdId);
const bodies = usable.map((x) => oneWayBody(x.h.holdId, [x.s.id], scid));
const { results, wallMs, releaseSpreadMs, overlap } = await fireAll(bodies.map((b) => () => book(b)));
const won = results.filter((r) => r.bookingRef);
note(won.map((r) => r.bookingRef));
return {
name: `diff-seats n=${n}`,
pass: won.length === usable.length && usable.length === n,
detail: `${won.length}/${usable.length} distinct-seat bookings succeeded (holds granted ${usable.length}/${n}) ` +
`${JSON.stringify(tally(results))} wall=${wallMs}ms release-spread=${releaseSpreadMs}ms overlap=x${overlap}`,
errs: errSample(results),
};
}
/** Overlapping multi-seat requests over a small shared pool — deadlock + partial-write probe. */
async function overlapping(pool, scid, n, poolSize = 4) {
const shared = pool.splice(0, poolSize);
const holds = await Promise.all(shared.map((s) => hold([s.id])));
const byId = new Map(shared.map((s, i) => [s.id, holds[i]]));
const granted = shared.filter((s) => byId.get(s.id).holdId);
if (granted.length < 2) return { name: `overlap n=${n}`, pass: false, detail: 'too few holds', errs: [] };
// Each request asks for 2 seats from the shared pool, in varying order.
const bodies = [];
for (let i = 0; i < n; i++) {
const a = granted[i % granted.length];
const b = granted[(i + 1 + (i % 2)) % granted.length];
if (a.id === b.id) continue;
// Present the hold of the FIRST seat; second seat will be rejected as not-in-hold.
bodies.push({ body: oneWayBody(byId.get(a.id).holdId, [a.id, b.id], scid), seats: [a, b] });
}
const { results, wallMs } = await fireAll(bodies.map((x) => () => book(x.body)));
const won = results.filter((r) => r.bookingRef);
note(won.map((r) => r.bookingRef));
return {
name: `overlap n=${bodies.length}`,
pass: true, // correctness judged by the DB duplicate check, not the count
detail: `${won.length} booking(s) from ${bodies.length} overlapping 2-seat requests over ${granted.length} seats ` +
`${JSON.stringify(tally(results))} wall=${wallMs}ms`,
errs: errSample(results),
};
}
/** Multi-passenger booking where one seat is contested — must be all-or-nothing. */
async function partialAvailability(pool, scid) {
const [a, b, c] = pool.splice(0, 3);
const hAll = await hold([a.id, b.id, c.id]);
if (!hAll.holdId) return { name: 'partial-availability', pass: false, detail: `3-seat hold failed: ${msg(hAll)}`, errs: [] };
// Book seat B alone first (legitimately, from the same hold).
const first = await book(oneWayBody(hAll.holdId, [b.id], scid));
if (!first.bookingRef) return { name: 'partial-availability', pass: false, detail: `setup booking failed: ${msg(first)}`, errs: [] };
note([first.bookingRef]);
// Now try to book all three. B is taken -> the whole request must fail, leaving no row.
const second = await book(oneWayBody(hAll.holdId, [a.id, b.id, c.id], scid));
if (second.bookingRef) {
note([second.bookingRef]);
return { name: 'partial-availability', pass: false, detail: `3-seat booking succeeded despite seat ${b.seatNumber} being taken — ${second.bookingRef}`, errs: [] };
}
return {
name: 'partial-availability',
pass: true,
detail: `seat ${b.seatNumber} taken -> 3-seat request rejected whole (${second.status}: ${msg(second)}); seats ${a.seatNumber},${c.seatNumber} must remain free`,
errs: [],
freeSeats: [a, c],
takenSeat: b,
};
}
/** N concurrent ROUND_TRIP bookings contesting one outbound+return seat pair. */
async function roundTripContest(n, scid) {
if (!RET_SCHED) return { name: `round-trip n=${n}`, pass: true, detail: 'skipped (no --return-schedule)', errs: [] };
const outPool = await seatPool(SCHED, ORIGIN, DEST);
const retPool = await seatPool(RET_SCHED, RET_ORIGIN, RET_DEST);
const outSeat = outPool[0];
const retSeat = retPool.find((s) => s.id !== outSeat.id) ?? retPool[0];
const traveller = uuid();
const hOut = await hold([outSeat.id], SCHED, ORIGIN, DEST, 'OUTBOUND', traveller);
const hRet = await hold([retSeat.id], RET_SCHED, RET_ORIGIN, RET_DEST, 'RETURN', traveller);
if (!hOut.holdId || !hRet.holdId) {
return { name: `round-trip n=${n}`, pass: false, detail: `hold failed out=${msg(hOut)} ret=${msg(hRet)}`, errs: [] };
}
const bodies = Array.from({ length: n }, () =>
roundTripBody(hOut.holdId, hRet.holdId, [outSeat.id], [retSeat.id], scid));
const { results, wallMs } = await fireAll(bodies.map((b) => () => book(b)));
const won = results.filter((r) => r.bookingRef);
note(won.map((r) => r.bookingRef));
return {
name: `round-trip n=${n}`,
pass: won.length === 1,
detail: `out seat ${outSeat.seatNumber} / ret seat ${retSeat.seatNumber}: ${won.length} booking(s) ` +
`${JSON.stringify(tally(results))} wall=${wallMs}ms refs=${won.map((r) => r.bookingRef).join(',')}`,
errs: errSample(results),
seats: { outSeat, retSeat },
};
}
/** A single multi-passenger, multi-seat booking — the ordinary family booking. */
async function multiPassenger(pool, scid, count = 4) {
const seats = pool.splice(0, count);
const h = await hold(seats.map((s) => s.id));
if (!h.holdId) return { name: `multi-passenger x${count}`, pass: false, detail: `hold failed: ${msg(h)}`, errs: [] };
const r = await book(oneWayBody(h.holdId, seats.map((s) => s.id), scid));
if (r.bookingRef) note([r.bookingRef]);
return {
name: `multi-passenger x${count}`,
pass: !!r.bookingRef,
detail: r.bookingRef
? `${count} passengers on seats ${seats.map((s) => s.seatNumber).join(',')} -> ${r.bookingRef}`
: `rejected (${r.status}): ${msg(r)}`,
errs: [],
seats,
ref: r.bookingRef,
};
}
// ─────────────────────────────────────────────────────────────────────────────
(async () => {
console.log(`API ${BASE}`);
console.log(`Schedule ${SCHED}`);
if (RET_SCHED) console.log(`Return ${RET_SCHED}`);
const pool = await seatPool();
const scid = await seatClassId(pool[0]);
console.log(`${pool.length} AVAILABLE seats, seatClass ${scid}\n`);
const out = [];
const report = (r) => {
out.push(r);
console.log(`${r.pass ? 'PASS' : 'FAIL'} ${r.name}`);
console.log(` ${r.detail}`);
if (r.errs?.length) r.errs.forEach((e) => console.log(` · ${e}`));
console.log('');
return r;
};
if (MODE === 'ladder' || MODE === 'all') {
for (const n of LEVELS) {
console.log(`──────── concurrency ${n} ────────`);
const hc = report(await holdContest(pool, n));
if (hc.winner) report(await bookingStorm(hc.seat, hc.winner.holdId, scid, n));
report(await differentSeats(pool, scid, n));
}
}
if (MODE === 'edge' || MODE === 'all') {
console.log(`──────── edge cases ────────`);
report(await multiPassenger(pool, scid, 4));
report(await partialAvailability(pool, scid));
report(await overlapping(pool, scid, 12));
report(await roundTripContest(10, scid));
}
const fails = out.filter((r) => !r.pass);
console.log(`════════ ${out.length} checks, ${fails.length} failed, ${ALL.created.length} bookings created ════════`);
fails.forEach((f) => console.log(` FAIL ${f.name}: ${f.detail}`));
if (ALL.created.length) {
console.log(`\nrefs: ${ALL.created.join(' ')}`);
}
process.exit(fails.length ? 1 : 0);
})().catch((e) => { console.error(e); process.exit(2); });