Nobody has started this yet — be first.
Business impact
ShortURL.to_dict() (used by GET /api/urls/<slug> and the list endpoint) computes click_count via self.clicks.count() -- a SELECT COUNT(*) FROM click_events WHERE short_url_id = ? on every single metadata request. For a link with modest traffic this is fine; for a link that has gone viral with millions of clicks, every metadata/list request pays for a full count scan over a table that only grows. A denormalized counter, updated once per click instead of scanned once per read, is the standard fix -- and the column for it (click_count) already exists in the schema (migration a7d7833a3e8e), it's just not wired up to anything yet.
Problem
click_count = db.Column(db.Integer, nullable=False, default=0, server_default="0") exists on ShortURL, but nothing anywhere increments it -- _record_click inserts a ClickEvent row and commits, and to_dict() still computes the count the old way (self.clicks.count()), completely ignoring the new column.
Current behavior
click_count sits at 0 forever regardless of real traffic -- after 3 redirects on a link, ShortURL.click_count still reads 0.
Expected behavior
Every successful redirect increments ShortURL.click_count by exactly 1, atomically, in the same transaction as the ClickEvent insert. to_dict() reads self.click_count directly instead of running self.clicks.count().
Steps to reproduce
Why this matters
The interesting part of this ticket isn't adding += 1 -- it's doing the increment in a way that's safe under concurrent clicks on the same link. short_url.click_count += 1; db.session.commit() (read-modify-write in Python) has a race: two concurrent requests can both read the same starting value and both write back old_value + 1, losing one increment. The safe version is an atomic, single-statement DB-side increment (e.g. UPDATE short_urls SET click_count = click_count + 1 WHERE id = :id, which SQLAlchemy can express without a Python- side read at all).
Suggested approach
Look at _record_click for where the ClickEvent is inserted and committed -- the increment belongs in the same function, same transaction. Search for how SQLAlchemy expresses an atomic column increment (hint: it is an UPDATE using the column itself in the SET expression, not a Python += on a loaded instance). Once increments are wired up and correct, swap to_dict()'s self.clicks.count() for self.click_count.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_15_click_count_column.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
Sign in to create it automatically, or do it yourself:
$git checkout -b feat/denormalized-click-countFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/denormalized-click-countSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign indocker-entrypoint.shcurl -X POST http://localhost:5000/api/urls \
-H "Content-Type: application/json" \
-d '{"target_url": "https://anthropic.com", "custom_slug": "claude"}'
# -> {"slug": "claude", "short_url": "http://localhost:5000/claude", "expires_at": null}
curl -i http://localhost:5000/claude # 302 redirect, records a click
make seed # or: docker compose exec web flask seed-db
If something is already listening on 5432/6379 locally, uncomment POSTGRES_HOST_PORT / REDIS_HOST_PORT in .env to remap the host side — the app always talks to db/redis over the compose network regardless. Run production-style (gunicorn, no dev server, no bind mount) with docker compose -f docker-compose.prod.yml up --build. GET /healthz checks both Postgres and Redis connectivity.
You do not need Docker running at all to work the tickets — the test suite uses TestingConfig (SQLite in-memory via StaticPool) and fakeredis instead of a real Postgres/Redis, so everything runs against a plain virtualenv:
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/pytest practice_tickets/tests/test_ticket_01_base62_roundtrip.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 20, clean pass/fail summary
./practice_tickets/run_tickets.sh 05 13 16 # just a few, by number
All 20 tests fail at the start; as you fix each ticket in app/, its test (and only its test) should flip to passing. .venv/bin/pytest tests/ (via make test) runs the project's own main suite — some ticket bugs (TICKET-05, 07, 08, 09, 14 among others) also break assertions in that suite, since they are real bugs in shared code paths; fixing the ticket fixes those too, not a separate task.
Level 3
Optimize performance
Same behaviour, better characteristics.