FROSH Ticketing System
Completed
Society · Backend

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.

Built with
DjangoDjango
PythonPython
PostgreSQLPostgreSQL
JavaScriptJavaScript
WhiteNoise
GunicornGunicorn
Project Details

STATUS
Completed
ROLE

Gate scanner — QR validation

ORGANISATION

Creative Computing Society

YEAR

2023

TYPE

Society · Backend

TAGS
Ticketing
Concurrency
Events

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_id with a validator that rejects anything not ending in @Frosh23, and carries max_capacity, passes_generated, and four independent booleans: booking_required, is_booking, is_display and booking_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_save signal on Event creates the first one automatically when slots_required is set.
  • EventPass joins a user to an event and optionally a slot, with a 16-character random pass_id, the QR URL, a time string and the entry_status boolean the gate flips. The event and slot foreign keys are on_delete=RESTRICT, so an event with issued passes cannot be deleted out from under them.
  • User replaces Django's default: username is dropped, registration_id becomes the USERNAME_FIELD, and each user carries an 8-character secure_id, a QR URL, a Postgres ArrayField of event names, and a hood foreign key. create_user generates 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_id and secure_id pair 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

STATUS
Completed
ROLE

Gate scanner — QR validation

ORGANISATION

Creative Computing Society

YEAR

2023

TYPE

Society · Backend

TAGS
Ticketing
Concurrency
Events