More Projects
59 in total
FROSH Ticketing System
Gate scanner — QR validation · Creative Computing Society · 2023
Event ticketing and attendee management for FROSH, built by a team of five on Django and PostgreSQL, with Gunicorn on gevent workers and static files served by WhiteNoise from inside the Python process rather than a separate web server. Akarsh built the gate scanner: an Instascan multi-camera reader with a separate ZXing path for iOS, plus the guard that stopped a pass being redeemed twice. The release itself was contended: 500 tickets against the roughly 3,000 students registered for FROSH 2K23, all arriving in the same second, and the allocation is an optimistic in-process counter rather than anything the database arbitrates.
Project Details
Gate scanner — QR validation
Creative Computing Society
2023
Society · Backend
FROSH is the freshers' welcome at Thapar, and the year it went ticketed there were 500 seats
against a registered population of about 3,000 — the same batch frosh-tiet-website was the
front door for. A ticket drop at a fixed time does not spread load, it concentrates it:
everybody arrives in the same second, which is why the request count and the population are the
same number rather than two. The settled figure from his resume is 3,000 requests for 500
tickets in 17 seconds with zero overbooking.
The code is Pancham1603/frosh-web-production, the Django deployment behind
events.froshtiet.com. It is 142 commits between 15 July and 11 August 2023 across five people,
and 24 of them are his, which makes him the second contributor on somebody else's repo. That
matters for reading this page honestly: the ticketing core is Pancham Agarwal's, and the piece
Akarsh owned end to end is the gate scanner. Both are described below, and which is which is
marked.
What the allocation code actually does
The naive shape is to count how many tickets have gone out, compare against the cap, and insert a row if there is room. That reads correctly and fails under load, because between the count and the insert there is a gap, and with thousands of requests landing together that gap is full of other requests running the same count against the same stale number and all reaching the same happy conclusion.
The fix in this codebase is not a database lock. apps/events/views.py opens with a
module-level dictionary:
global counters
counters = {}
for event in Event.objects.all():
counters[event.name] = event.passes_generated
for slot in EventSlot.objects.all():
counters[slot.slot_id] = slot.passes_generated
That runs once, at import. From then on generate_pass increments counters[event.name]
before it creates anything, calls event.refresh_from_db(), and only then checks
counters[event.name] <= event.max_capacity. If the check passes it mints a 16-character
pass_id, saves the EventPass, and writes the counter back onto event.passes_generated. If
the check fails it decrements the counter it just raised, sets booking_complete = True and
is_booking = False on the event, saves, and returns "No more passes available for this event".
The per-slot branch above it does the same thing keyed on slot_id.
So the contested resource is a Python integer in the web worker's memory, and the increment
happens before the write rather than after the read. Within a single process that closes the
window the naive version leaves open, because CPython will not interleave two requests midway
through counters[k] += 1 in a way that lets both observe the pre-increment value, and because
nothing downstream is allowed to act on a number read earlier. The overflow path is also cheap,
which matters more than it looks: 500 people get a ticket and 2,500 do not, so the losing path
is five times the traffic, and here losing costs a dictionary decrement and two boolean writes
rather than a transaction and a rollback.
The trade that design makes
Being straight about it: this is correct for one process and undefined for more than one. Each
Gunicorn worker that imports the module gets its own counters dict seeded from the same
starting value, so N workers can each independently believe they have the full cap available.
The deployment topology is doing load-bearing work that the code does not state anywhere. A
database-arbitrated version — a select_for_update across the check, or a constraint that makes
the 501st row impossible, or an update whose affected-row count is the verdict — would hold
regardless of how many workers are running, and would survive somebody scaling the service
horizontally without reading this file.
What the in-process counter buys in exchange is that the hot path touches Postgres once, for the insert, instead of taking and holding a row lock across the whole check-and-write while 3,000 requests queue behind it. On a burst that is over in seconds, on a managed Postgres with a finite connection budget, that is not a stupid trade. It is a trade that should have been written down.
The gate is the part he owned
Selling the ticket correctly is only half of it. On the night, a volunteer with a phone has to
decide in about two seconds whether the person in front of them is the person the ticket belongs
to, in a queue, in bad light. apps/validation/ is that, and it is his: "Scanner Design and
model integrated" (25 July), "scanner camera view for mobiles" (24 July), "Old Scanner" and
"scanner attempted fixes" (29-30 July), "Close Modal on pass validation Fixes#12" (9 August),
and "Clan Prefrences and I phone Scanner Bug" (11 August). The double-booking guard in the
events view — the EventPass.objects.filter(user_id=user, event_id=event) check that stops a
user claiming twice — is his "fixed multiple pass bug" commit on 27 July.
The scanner is two implementations of the same screen, because one browser would not cooperate.
camera.js is the general case: Instascan.Camera.getCameras() enumerates the device's
cameras, builds a picker, and starts a stream with facingMode: "environment" and an exact
deviceId, with a switch-camera control and a catch that falls through to the next camera
when a constraint is rejected. camerai.js is the iOS path, and it does not use Instascan at
all: it opens the stream itself, injects the ZXing library at runtime, and drives
BrowserQRCodeReader.decodeOnceFromVideoDevice in a self-rescheduling loop. The view picks
between them on request.iOS, supplied by django-detect's user-agent middleware, and gates
the whole page on request.user.is_staff.
What comes back from a scan is the point. fetch_user_data accepts either a bare 16-character
pass_id or a JSON blob carrying registration_id and secure_id, resolves it to the pass,
checks the pass belongs to the event the operator selected, and returns the holder's name,
registration ID and photo URL along with valid and a message. A pass already scanned comes
back with valid: false and "Pass has already been used". Confirming sends the pass id to
userdata/validate, which flips entry_status. The volunteer is not being asked whether the
QR is real, they are being asked whether the face matches the photo on their screen, which is a
comparison humans are already good at. A QR code is a string and a string is trivially forwarded
over WhatsApp, so a scanner that answers only "is this a real ticket" answers yes to every copy
of it.
The data model
- Event is keyed by a text
event_idwith a validator that rejects anything not ending in@Frosh23, and carriesmax_capacity,passes_generated, and four independent booleans:booking_required,is_booking,is_displayandbooking_complete. - EventSlot exists because some events ran in sittings. It has its own 16-character random
primary key, its own capacity, venue, date, time and calendar URL, and a
post_savesignal on Event creates the first one automatically whenslots_requiredis set. - EventPass joins a user to an event and optionally a slot, with a 16-character random
pass_id, the QR URL, atimestring and theentry_statusboolean the gate flips. The event and slot foreign keys areon_delete=RESTRICT, so an event with issued passes cannot be deleted out from under them. - User replaces Django's default:
usernameis dropped,registration_idbecomes theUSERNAME_FIELD, and each user carries an 8-charactersecure_id, a QR URL, a PostgresArrayFieldof event names, and ahoodforeign key.create_usergenerates the secure id, sets a random 8-character password for non-superusers, and retries three times on failure. - Hood is the clans app, present only in the production repo and not in the dev fork.
Surface area
- Event listing at
/events/, splitting past, today and upcoming, with the first event of today promoted to a live slot and each user's own passes listed alongside. - Claim endpoints at
/events/register/<event_id>and/events/register/<event_id>/<slot_id>, both@login_required, returning JSON rather than a redirect so the page can stay put. - Server-side QR generation through PyQRCode into a per-registration PNG, encoding the
registration_idandsecure_idpair rather than a guessable sequence. - Gate scanner at
/scanner/, staff-gated, with separate desktop/Android and iOS implementations behind one URL. - Identity confirmation on scan, returning the holder's photo and registration ID for the volunteer to check.
- Single-use enforcement, with the second presentation of a forwarded code rejected by the system rather than by somebody's memory.
- Email activation flow at
/activate/<secure_id_b64>/<token>using Django's token generator. - Bulk import through
openpyxl, reading a participant spreadsheet and creating users, QRs and passes in one pass. - Admin on
django-jet-reboot, mounted at/bablucopter/rather than/admin/.
How it runs
Django 4.2 on PostgreSQL through psycopg2, with credentials read from the environment via
python-decouple and a ca-certificate.crt checked in for a managed database that requires
verified TLS. Gunicorn 21.2 with gevent workers handles the requests, which is the right
choice for a burst that is mostly waiting on the database rather than computing anything.
Static files are served by WhiteNoise from inside the Python process, not by a separate web
server. django-imagekit and imagekitio handle attendee photos, and a .github workflow
directory in both repos means the deploy was CI-driven. frosh-web is the development fork the
production repo was branched from; it lacks the clans app and carries a checked-in db.sqlite3.
Honest limitations
The confirmation email is written and wired but commented out at the call site in
generate_pass, so during the live drop nobody was mailed a ticket; the only path that sends
one is the bulk import helper. ALLOWED_HOSTS is ['*']. check_count() is a bare
while True: that prints a queryset count, left sitting in the views module next to the
production handlers, along with generate_password(), which resets a specific hardcoded
registration ID to 12345678. A large commented-out block implements a special case for linking
two Whodunit events, duplicating the entire allocation branch, which is how that function got to
its current length. The counter dict is keyed on event.name for events but slot_id for
slots, so renaming an event after import silently orphans its counter. And the whole in-process
design has no test that would catch its failure mode, because the failure mode only appears with
more than one worker and more than one simultaneous claimant.
Seventeen seconds is not fast because the server is fast. It is fast because the contested resource is contended in exactly one place, and the counterfactual is not a slower system, it is one that hands out 540 tickets and turns the welcome event into an argument at the gate.
Project Details
Gate scanner — QR validation
Creative Computing Society
2023
Society · Backend