Postmortem: RunPod Image Regression (v47)
:::warning Audit scope
This is a historical record whose primary evidence belongs to the excluded GPU
backend. It was not re-verified during the 2026-07-23 non-GPU audit, so its
older last_verified date is intentional. Preserve the original timeline;
label any later correction explicitly.
:::
| Field | Value |
|---|---|
| Date | 2026-03-14 |
| Severity | ๐ด Critical |
| Duration | 2h 18m (09:42 โ 12:00 UTC) |
| Affected users | 100% of new uploads during the incident window (~18 users) |
| Triggered by | GPU backend deploy (v47 Docker image) |
| Discovered by | User complaint in support channel |
| Authors | @akshay |
| Status | โ Published |
TL;DRโ
A new GPU backend image (akshaysarode/acesense:v47) was pushed to RunPod at 09:42 UTC. The image had a silent regression in a model-loading path that only triggered on videos shorter than 60 seconds โ our local smoke test used a 30-second clip, but ran with the dev cache enabled, which masked the bug. All short videos uploaded to production failed for 2h 18m until a user reported it and we rolled back to v46.
Timeline (UTC)โ
| Time | Event |
|---|---|
| 09:42 | docker push akshaysarode/acesense:v47 โ new image live |
| 09:44 | RunPod endpoint updated to v47 |
| 09:47 | First production video fails โ silent, no alert fires |
| 10:30 | More failed uploads โ still no alert |
| 11:05 | ๐จ User @beta-user posts in #acesense-dev: "my upload has been stuck for an hour" |
| 11:07 | @akshay opens RunPod console โ sees 14 failed jobs with identical stack trace |
| 11:14 | Diagnosis: error is in src/players/__init__.py โ new FasterRCNN init path crashes on short videos |
| 11:20 | Decision: rollback instead of forward-fix (couldn't root-cause in < 30 min) |
| 11:25 | RunPod endpoint tag reverted v47 โ v46 |
| 11:28 | New uploads start processing successfully |
| 11:40 | Retried the 14 failed jobs manually via admin panel |
| 12:00 | โ All 14 affected uploads now have results |
| 14:30 | Root cause PR opened (local test with --dev cache was hitting a stale code path) |
| Next day | v48 with fix + a proper smoke test shipped |
Impactโ
- User-visible: Every upload between 09:47 and 11:25 got stuck in
running. Users saw an indefinite spinner. No error surfaced. - Data loss: None. All 14 videos were re-processed after rollback; their analyses were identical to what they would have been.
- Cost impact: ~$2 of wasted GPU compute on failed jobs. Negligible.
- Trust impact: One user sent a frustrated message in Slack. No churn, but the session was painful.
Root Causeโ
Proximate cause: src/players/__init__.py line 42 raised AttributeError when the ball trajectory was shorter than 60 frames. The new code assumed a minimum 2-second temporal window after a refactor, but never validated.
Contributing factors:
- Local smoke test ran with
--devโ the dev cache short-circuits player detection entirely, so the bug was masked - No CI for the GPU backend โ pytest exists but isn't run on PR
- No production smoke test โ after pushing an image, we didn't fire a test inference against it
- No alert on function error rate โ
processVideoOnUploadwas loggingRunPod API errorlines, but nothing was watching - Min workers = 0 โ all new jobs hit the new image immediately; there was no canary
Why we didn't catch it earlier:
- The failure was silent to users (just a longer spinner)
- Our only detection was a user complaint in Slack โ no alerting
Mitigationโ
docker pull akshaysarode/acesense:v46to confirm the old image was still available- In RunPod console โ Endpoint โ edit โ change tag
v47โv46โ save - Wait ~60s for new workers to spin up with the old image
- Test with a known-good video upload โ โ working
- Open Admin panel โ Jobs tab โ filter
status == failed && createdAt > 09:47โ click "Retry" on each (14 jobs) - Post status update in
#acesense-dev
What went wellโ
- Rollback was a single click once the bad image was identified
- Admin panel's manual retry worked on every failed job
- No data loss โ the raw videos were still in Storage
- @akshay happened to be online and saw the Slack message within 2 minutes
What went poorlyโ
- 2 hours passed before anyone noticed
- The local test run passed โ false confidence
- No automation for the rollback steps โ all clicks
- Users had no indication their upload failed (the Flutter UI still showed progress)
Where we got luckyโ
- The bad image only affected new jobs โ anything already running was on the old image
- A vocal beta user alerted us โ paying users might have churned silently
Action itemsโ
| # | Action | Owner | Due | Priority | Status |
|---|---|---|---|---|---|
| 1 | Add Cloud Monitoring alert on processVideoOnUpload error rate > 5% / 5 min | @akshay | 2026-03-21 | ๐ด P0 | โ Done |
| 2 | Add a deploy smoke test: after Docker push, run an input_videos/sample.mp4 inference against the new tag and verify exit 0 | @akshay | 2026-03-28 | ๐ด P0 | โ Done |
| 3 | Run pytest tests/ on every GPU backend PR via GitHub Actions | @akshay | 2026-04-05 | ๐ก P1 | ๐ก In progress |
| 4 | Set RunPod min workers = 1 during business hours as a canary buffer | @akshay | TBD | ๐ข P2 | โ ๏ธ Deferred (cost tradeoff) |
| 5 | Surface upload failures to the Flutter UI (currently silent) | @akshay | 2026-04-12 | ๐ก P1 | ๐ก In progress |
| 6 | Add this incident to the Job Stuck runbook as a known cause | @akshay | 2026-03-15 | ๐ข P2 | โ Done |
Lessons Learnedโ
- Local tests must match production. Running with
--devis for iteration speed, not validation. The smoke test that gates a push should run the exact same code path as production. - If it's not monitored, it's not monitored. We had all the log data but no one was reading it. An alert with a pager beats a dashboard no one looks at.
- Users should never be the first to notice. Every minute between "it broke" and "we noticed" is trust bleeding out.
- Rollback is not a plan B. It's plan A for most incidents โ make it fast and scriptable.
Supporting dataโ
-
Failed job IDs:
fd4a1,e9b3c, โฆ (14 total โ retrieved from Firestorejobswith querystatus == failed && createdAt > 09:47) -
Stack trace (excerpt):
File "/app/src/players/__init__.py", line 42, in load_modeltrajectory_window = features[:60]AttributeError: 'NoneType' object has no attribute '__getitem__'