Cache adsb.lol responses and stop writing them back to the readsb file - #19
Conversation
ReviewGood fix — the core problem (adsb.lol data being written into readsb's own output file and read back as Bug: failure-path retries aren't rate-limited by the TTL
const age = cache ? Date.now() - cache.fetchedAt : Infinity;
if (age >= CACHE_TTL_MS) {
await refreshRemote();
}If adsb.lol is down (or has never succeeded), Missing: no User-Agent on the upstream requestThe PR description calls out that hammering adsb.lol "with no User-Agent identifying who is doing it" is plausibly why they added the contact-info rule — but Minor: refresh blocks the triggering requestWhen the cache expires, the request that trips Nit: removed try/catch around
|
The proxy wrote every adsb.lol response into LOCAL_DATA_PATH
(/run/readsb/aircraft.json) "so tar1090's backend process can read it". That
file is readsb's own output, rewritten at 1 Hz, so the two writers raced for
it - and because getAircraftData() reads the local file first, a response that
had just been fetched from adsb.lol would come back on the next request
labelled source: 'local'. Remote data was laundered as local data, and the
X-Data-Source header could not be trusted to say where a fix came from.
Serve from an in-process cache instead. Nothing writes to the local file any
more; readsb owns it exclusively.
Alongside that:
- Concurrent requests collapse onto one upstream fetch via a shared inFlight
promise, rather than each issuing its own call.
- A failed refresh leaves the previous cache in place and never rejects, so
a flaky upstream degrades into stale-but-served instead of an error.
- Responses carry X-Data-Age-Ms and X-Data-Stale, and payload.now is stamped
at fetch time and never restamped on serve, so consumers can tell how old
a position actually is. blah2-api's extrapolation refuses to project more
than 5 s, which is what CACHE_TTL_MS (3 s) is sized against.
- MAX_STALE_MS bounds how long a dead upstream keeps being served; past it
the response reports source: 'none' rather than passing off an empty sky
as a successful read.
- Non-200 responses call res.resume() so the socket is drained and freed.
- /health reports cache age and cached aircraft count.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Rebased onto Both changes touched
Cross-check: the resolved file is byte-identical to what has been running on jonathan-node-1 since yesterday, apart from that one added comment. Re-tested against the live API after the rebase: Check 3 is the regression test for the bug this PR fixes — under the old code that request sequence created the local file and the second response came back labelled |
ffda833 to
9837379
Compare
ReviewThis is a solid fix for a real data-integrity bug — using an in-process cache instead of writing adsb.lol data into readsb's own output file is clearly the right call, and the reasoning in the PR description (reproduced against a live node) makes the "two writers, one file" problem easy to verify. A few notes below, nothing blocking. Code quality
Potential bug (minor)In return { data: localData || emptyPayload(), source: 'none', ageMs: 0, stale: false };reports Minor / non-blocking
Test coverageNo tests are included, but the repo has no JS test framework/harness currently (no SecurityNo concerns — Overall: correctness fix is sound and well-justified, the caching/dedup logic is careful, and the only real ask is to double check the local-empty-but-present labeling edge case above is the intended behavior. |
Review catch on #19. The refresh gate compared against cache.fetchedAt, which is only stamped on a *successful* fetch: const age = cache ? Date.now() - cache.fetchedAt : Infinity; if (age >= CACHE_TTL_MS) await refreshRemote(); So while adsb.lol is failing, `cache` stays null, `age` stays Infinity, and every request starts its own fetch. inFlight collapses concurrent callers but not sequential ones, so the cache halved upstream load when things worked and did nothing at all when they did not - reverting to the full client poll rate precisely when adsb.lol is least able to serve it. That is the opposite of what this PR is for, and it is not hypothetical: jonathan-node-1 was measured at ~33 upstream requests/min through the 403 outage, against ~14/min once the cache was being populated. Track lastAttemptAt, set when an attempt begins regardless of outcome, and refresh only when the cached payload has aged out AND no attempt has been made within the same window. Measured against the live API, 20 requests over 10 s with the upstream deliberately 403-ing (ADSBLOL_USER_AGENT set to a blocked token): before 20 upstream attempts (one per request) after 4 upstream attempts (one per 3 s TTL) Success path unchanged: 6 requests over 6 s produce 2 upstream fetches, and the served payload is unaffected. Also documents the four ADSBLOL_* tuning vars in .env.example, which already carried ADSBLOL_ENABLED and ADSBLOL_RADIUS, and records at the call site that getAircraftData() is contracted never to throw - an invariant currently spread across readLocalFile() and refreshRemote() with nothing enforcing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — worked through all of it. Pushed Fixed: failure-path retries weren't throttledConfirmed, and it's the substantive one. Worth noting this wasn't hypothetical. Measured on jonathan-node-1 before anyone looked for this bug:
So the cache halved upstream load when things worked and did nothing when they didn't — the opposite of the intent. Fixed by tracking Measured against the live API, 20 requests over 10 s with the upstream deliberately 403-ing ( Success path unchanged — 6 requests over 6 s produce 2 upstream fetches. Fixed:
|
ReviewThis is a well-targeted fix for a real data-integrity bug (two writers racing on readsb's own output file), and the second commit catches a genuine flaw introduced by the first (the refresh gate not throttling failed fetches) — good iteration, and both are backed by measurements against a live node rather than just reasoning about the code. Strengths
Things worth a look
Test coverageNo test harness exists in this repo (it's install-script/shell-centric, no SecurityNothing concerning — this only changes caching/fallback logic for a public read-only aircraft feed, no new user input paths, and the User-Agent value is only ever sent outbound, never reflected. |
Split out of #17 so it can be reviewed on its own merit rather than riding behind a one-line header fix. This is a data-integrity fix, not a refactor.
1. The proxy wrote adsb.lol data into readsb's own file
LOCAL_DATA_PATHis/run/readsb/aircraft.json— readsb's output file, which readsb rewrites at 1 Hz. Two writers, one file, no coordination.Because
getAircraftData()reads the local file first, the next request reads back the adsb.lol payload it just wrote and reports it assource: 'local'. Reproduced against the live API:X-Data-Sourcecannot be trusted to say where a fix came from, which matters given the wrong-region incidents we've chased.The worse case is a node that actually has a receiver. When readsb momentarily reports zero aircraft — a normal gap — the proxy fetches adsb.lol and overwrites the real receiver's output with remote aircraft. Anything reading that file directly rather than through the proxy then sees another region's traffic as if the local receiver produced it.
Fixed by serving from an in-process cache. Nothing writes to the local file; readsb owns it exclusively.
2. One upstream fetch per HTTP request
Every request with an empty local file triggered a fresh fetch.
receiver.jsonsets"refresh": 1000, so each open map polls at 1 Hz, plus adsb2dd on its own cadence.Measured on jonathan-node-1:
Roughly a 60% reduction. Worth noting that hammering a free community API at that rate from every node, with no User-Agent identifying who is doing it, is plausibly related to why adsb.lol introduced the contact-info rule in the first place — though I can't prove the connection.
3. Everything else
inFlightpromise instead of each issuing its own.X-Data-Age-Ms/X-Data-Staleexpose staleness, andpayload.nowis stamped at fetch time and never restamped on serve. blah2-api's extrapolation refuses to project more than 5 s, which is what the 3 s TTL is sized against.MAX_STALE_MSbounds how long a dead upstream keeps being served; past it the response reportssource: 'none'rather than passing off an empty sky as a successful read.res.resume()so the socket is drained./healthreports cache age and cached aircraft count.Note on provenance
This work was already present uncommitted in the working tree; I've committed it as-is without modification and reconstructed the rationale above from the code and from measurements on a live node. If the original author had other reasons, they aren't captured here.
Touches the same
fetchUrlas #18, so whichever lands second needs a rebase. #18 is the urgent one.🤖 Generated with Claude Code