The Bug That Reverted Every Trade: Three Silent Failures Hiding Behind Each Other

Ballpark Genius is an MLB analytics site — player pages, projections, trade tracking, semantic search over the whole league. In one debugging session this week I chased a single symptom (“Adley Rutschman still shows as an Oriole five days after Baltimore traded him to Boston”) through three unrelated bugs, each of which perfectly hid the next one. None of them threw. None of them logged an error. Every one of them printed a success message while doing nothing, or the wrong thing.

This is the write-up, in the order I actually found them, because the order matters: each fix revealed the next failure instead of resolving the symptom.

Adley Rutschman's Ballpark Genius player page, correctly showing "Traded 8 days ago" from Baltimore to Boston and his current team as Boston Red Sox

Adley Rutschman’s page today — the state all three bugs conspired to prevent.

Bug 1: the cache flush that flushed nothing

The first theory was stale cache. I ran the project’s clear-cache script, expected a clean slate, and got the same stale data back. Ran it again. Same result. The script printed ✓ cleared every time.

The cause was an environment mismatch that had been there since the caching layer was built:

1
const CACHE_NAMESPACE = (process.env.CACHE_NAMESPACE || process.env.NODE_ENV || 'dev').trim();

The server runs with NODE_ENV=development and writes keys under development:*. The CLI script, run from a plain shell, has no NODE_ENV set at all — it resolves to dev:, issues a delete against dev:*, matches zero keys, and reports success anyway, because delPattern returned void. A zero-match delete was indistinguishable from a real one, at the type level and in the printed output.

I only found it by going around the tool: redis-cli EXISTS against the development:* keys directly, before and after a “successful” flush. The keys were still there. That’s when I noticed the namespace mismatch.

The fix made a no-op impossible to mistake for success:

  • delPattern now returns the number of keys it actually deleted, and every call site reports that count — ⚠️ 0 entries cleared reads very differently than ✓ cleared.
  • The tool now prints the namespace it’s about to operate on and what Redis actually holds, so a mismatch is visible before you even wait for the delete.
  • It detects the mismatch and tells you the fix:
1
2
3
4
Namespace: dev:  (Redis currently holds: development, test, today)
⚠️ No keys exist under "dev:" — this flush will delete NOTHING.
Keys live under: development:, test:, today:
Re-run matching it, e.g. NODE_ENV=development npm run clear-cache -- ...

This had been the standing explanation for a whole class of “why didn’t my fix take effect” reports across the project. The remedy people reached for first had never actually worked.

Bug 2: the importer that outvoted the trade

With caching ruled out, the database itself said Rutschman was still Baltimore’s. A trade sync job (refresh-player-teams) had correctly written his real club days earlier. Something was writing it back.

The culprit was a stats importer — a job that pulls a player’s current-season batting or pitching splits and, as an incidental side effect, decided it should also own that player’s team assignment:

1
2
3
4
// Update player's team if this is the most recent MLB team ...
if (player && teamId && teamName && season === CURRENT_SEASON && isMLBTeamId(teamId)) {
await prisma.player.update({ where: { id: playerId }, data: { teamId, teamName } });
}

The comment claims “most recent.” There is no recency check anywhere in that block — it simply overwrites with whatever split MLB’s API happens to return next, in whatever order the API happens to return them. For a player traded mid-season, the old club is often the only one with any stat rows at all: Rutschman had two Baltimore rows for 2026 and zero for Boston, because he went straight to Boston’s injured list without playing a game there yet. So every time his stats got re-synced — including just from a page view triggering a background refresh — his team flipped back to the club that had traded him away, silently, with a normal-looking updated_at timestamp as the only trace.

The fix was to make that write fill-only: populate a missing team, never overwrite an existing one. A stats importer has no business deciding a player’s current club — that’s what the trade sync and MLB’s own parentOrgId field are for. The importer can still help a player that those other syncs haven’t reached yet, which is the entire reason the write existed in the first place; it just can’t outrank them anymore.

Fixing this repaired 51 players who’d been reverted by exactly this path.

Bug 3: the schema that quietly dropped the answer

There was a third bug feeding into the same symptom, found a few days earlier in the same chase: fix-milb-teams, a job meant to correct minor-league team assignments, saw Rutschman parked on a non-MLB affiliate, decided that looked broken, and “fixed” it by picking whichever MLB club he’d played the most games for historically — which, for a player traded five days ago onto an injured list, is always the old team. Volume-based inference is exactly backwards for someone who just moved.

MLB’s API does hand back the correct answer directly, as currentTeam.parentOrgId. But our Zod schema for the player payload didn’t declare that field, and Zod drops unknown keys by default — so the correct value arrived from MLB and was silently discarded before any of our code ever saw it. The first draft of this fix read parentOrgId, got undefined, and would have quietly fallen back to the same broken most-played-games logic while looking like it had been fixed. I only caught that by checking the parsed output against the raw response, not trusting that adding a schema field was enough on its own.

With parentOrgId declared and read, fix-milb-teams now asks MLB directly instead of inferring from history. Repair applied to the 328 players whose team contradicted their most recent trade: 52 genuinely wrong, corrected; 276 were false positives from the detection query itself — players like Verlander and Scherzer, whose team correctly differs from an old trade because they’ve since moved again on their own. The script checks with MLB before touching anyone, so only the 52 real mismatches changed.

What all three had in common

None of these bugs were exotic. A namespace typo. A missing recency check with a comment that lied about having one. A schema that silently dropped a field. Individually they’re the kind of thing you’d catch in five minutes. What made them dangerous together is that each one’s silence looked exactly like the previous fix working: flush the cache (nothing happens, but it says it worked), so you conclude the data itself must be wrong; fix the importer (the revert stops for a while, until the next stats sync), so you conclude the trade sync must be flaky; add the schema field (still wrong, because the value was never read), so you conclude the API itself must be inconsistent.

The lesson I keep relearning on this project: a tool that can silently do nothing is worse than a tool that fails loudly, because it burns your best hypothesis on every pass. The actual fix, each time, was smaller than the investigation it took to find it.

— James