mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 10:08:21 +00:00
360 lines
16 KiB
JavaScript
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); });
|