A community report about a 42 FOAM discrepancy led us down a rabbit hole of PostgreSQL behavior, timestamp precision, and ultimately to a complete rewrite of how we match structure kills to killmails. What we found was a subtle but critical bug: the same database query could return different results on every run, causing player statistics to fluctuate randomly.
The Bug Report
A player reported that their FOAM (Fuel of Autonomous Machines) destroyed total on the killboard showed 418.15, but they believed it should be 376.15—or maybe it was the other way around. The numbers kept changing. This was concerning: FOAM is a key metric in EVE Frontier that measures combat impact, and players use these statistics to track their progress and compare performance.
Our killboard system had been running successfully for weeks, processing over 1,000 kills and attributing FOAM values based on structure types (Small Gates = 43 FOAM, Smart Turrets = 1 FOAM, etc.). The values are derived from matching killmails to structure destruction events that occur within a 60-second window. But apparently, something was wrong with how we were doing that matching.
Understanding the Data Model
To understand the bug, you need to understand how we calculate FOAM. In EVE Frontier, when you destroy a player's ship, any structures they own that are destroyed around the same time get attributed to that kill. We have two data sources:
- Kill mails from the blockchain—recording who killed whom, when, and in what system
- Structure destruction events—recording which structures were destroyed, when, and who owned them
The challenge is matching these two datasets. A killmail says "Player A destroyed Player B's ship at 12:34:56". A structure destruction event says "Player B's Small Gate was destroyed at 12:34:57". If the times are close (within 60 seconds) and it's the same victim, we attribute that structure's FOAM to the killer.
The Timestamp Precision Problem
Here's where it gets interesting. Killmails have second-level precision—you might have three kills all timestamped at exactly 12:34:56. But structure destructions have millisecond precision—12:34:56.234, 12:34:56.789, etc. When players are rapidly destroying structures, you can have multiple killmails in the same second, each potentially matching multiple structures.
The question becomes: which kill gets credit for which structure?
The Original (Broken) Approach
Our original SQL query used PostgreSQL's DISTINCT ON clause to ensure each kill only matched one structure:
SELECT DISTINCT ON (kill_mail_id)
km.kill_mail_id,
dao.assembly_id,
dao.type_id
FROM kill_mails km
JOIN destroyed_structures dao
ON dao.owner = km.victim
AND ABS(km.timestamp - dao.timestamp) < 60 seconds
This looks reasonable—for each kill, pick one matching structure. But there's a critical oversight: we didn't specify an ORDER BY clause.
PostgreSQL DISTINCT ON Behavior
When you use DISTINCT ON (column) without an ORDER BY, PostgreSQL picks an arbitrary row from each group. The database documentation explicitly warns: "The DISTINCT ON expression(s) must match the leftmost ORDER BY expression(s). The ORDER BY clause will normally contain additional expression(s) that determine the desired precedence of rows within each DISTINCT ON group."
Without ORDER BY, the row selected depends on internal factors like index order, table organization, and query planning—none of which are deterministic across runs.
Why This Caused Fluctuating Values
Consider a scenario: Player A kills Player B at 12:34:56. Player B had two Small Gates destroyed—one at 12:34:56.234 and another at 12:34:56.789. Both are within the 60-second window, so both are valid matches.
Without ORDER BY, PostgreSQL might pick the first gate on Monday's run, but the second gate on Tuesday's run. If another kill from Player C was also matching one of those gates, the FOAM attribution could flip between players randomly.
This explains the 42 FOAM difference reported—the equivalent of one Small Gate (43 FOAM) being attributed differently between runs.
Investigating the Specific Case
We dug into the data for the reported player. They had 211 total kills and 96 kills that matched at least one structure. The structure breakdown showed:
| Structure Type | Type ID | Unique Structures | Total Match Candidates |
|---|---|---|---|
| Smart Turret | 84556 | 69 | 205 |
| Small Gate | 88086 | 6 | 9 |
| Refinery L | 88064 | 3 | 3 |
| Hangar L | 88094 | 2 | 2 |
The key insight: 6 unique Small Gates, but 9 total match candidates. This means some gates could match multiple kills. The old query was randomly picking which kill got which gate—explaining the fluctuating totals.
The Solution: Greedy 1:1 Matching
Instead of letting the database arbitrarily pick, we implemented a greedy matching algorithm in JavaScript:
- Fetch all candidates: Query returns ALL possible (kill, structure) pairs within the 60-second window, sorted by time difference ascending
- Process in order: Iterate through the sorted candidates
- Match closest first: For each candidate, if neither the kill nor structure has been matched yet, match them together
- Mark as used: Track used kills and structures in Sets to prevent double-matching
// Greedy 1:1 matching algorithm
const usedKills = new Set();
const usedStructures = new Set();
// Candidates are pre-sorted by time_diff ascending
for (const row of candidateRows) {
const killId = row.kill_mail_id;
const structureId = row.assembly_id;
// Skip if already matched
if (usedKills.has(killId) || usedStructures.has(structureId)) {
continue;
}
// Match this kill to this structure
usedKills.add(killId);
usedStructures.add(structureId);
const foamValue = getFoamValue(row.type_id);
structureKillsMap.set(killId, { foamValue, ... });
}
Why This Works
The greedy approach ensures:
- Determinism: Same input always produces same output—the sort order is based on time difference, which is a fixed value
- Fairness: The kill closest in time to a structure destruction gets the credit
- No double-counting: Each structure can only be attributed to one kill, and each kill can only claim one structure
The key insight is that by sorting ALL candidates globally (across all killers) by time difference, we naturally resolve conflicts. If two different players' kills both could match the same structure, the one with the smaller time difference wins.
Verification
After deploying the fix, we ran the exporter multiple times and compared outputs:
| Metric | Run 1 | Run 2 | Run 3 |
|---|---|---|---|
| Total FOAM | 663.65 | 663.65 | 663.65 |
| Matched Kills | 231 | 231 | 231 |
| Candidate Rows | 377 | 377 | 377 |
| Output Size | 332,495 bytes | 332,495 bytes | 332,495 bytes |
Identical results across all runs—exactly what we wanted.
Lessons Learned
Key Takeaways
- Always ORDER BY with DISTINCT ON: PostgreSQL's behavior without an explicit ORDER BY is undefined and non-deterministic
- Timestamp precision matters: When joining tables with different precision levels, conflicts can arise that need explicit resolution
- Move complex matching to application code: SQL is great for filtering and sorting, but for complex 1:1 matching with constraints, procedural code is clearer and more maintainable
- Test idempotency: Run data processing jobs multiple times and compare outputs—fluctuating results indicate hidden non-determinism
This bug had been present since the original killboard implementation, but went unnoticed because the fluctuations were small enough that they seemed like normal data changes. It took a player carefully tracking their stats to catch it.
The Fix in Production
The fix was deployed with a Docker image backup for easy rollback if needed. The exporter now runs every 5 minutes with consistent, reproducible results. The reported player's FOAM total stabilized at 412.15—not 418.15 or 376.15, but the correct value based on the deterministic matching algorithm.
The slight difference from their expected values came from the global nature of the matching: some structures they could have claimed were actually matched to other players whose kills had a smaller time difference. The greedy algorithm ensures fairness across all players, not just optimal attribution for any single player.
Related Posts
- Killboard Implementation: Tracking PvP Activity Across EVE Frontier - The original killboard system architecture that this fix improves
- Database Architecture: Blockchain Indexing for EVE Frontier - How we index blockchain data that feeds the killboard
- Hetzner VPS Migration: Moving 19 Docker Containers from Local to Cloud - The infrastructure running the killboard exporter
- Cloudflare KV Optimization: 93% Size Reduction - How we optimize the KV snapshots that store killboard data