fix(firms): record source success after aggregation (#181)

Record FIRMS source success after appending its rows so aggregation failures do not produce contradictory source status. Cover large feeds, filtering, ordering and source failures with regression tests.
This commit is contained in:
mwilliamsGH authored and GitHub committed 2026-09-10 21:13:46 -07:00
1 parent 87a92c8bc4
commit e7cb08f5f6
4 files changed
+90 -1

No files matched your search

+2
View File
@@ -15,6 +15,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Fixed
- FIRMS records a source as successful only after appending its rows, avoiding
contradictory success/failure status if aggregation throws.
- Radio country filtering and voice country requests now resolve common English
names and exonyms that `Intl.DisplayNames`' primary label omits, so requests
like "play radio in Turkey" no longer fail closed (Turkey → Türkiye, plus
+7
View File
@@ -10,6 +10,13 @@ Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing n
Updated: August 24, 2026
## FIRMS source status
The FIRMS proxy records source success after appending its rows. If aggregation
throws, that source reports failure without a contradictory success entry.
The existing per-record append continues to support large feeds; sequential
fetching, trailing-24-hour filtering and partial-success caching are unchanged.
## Installations and map-source guidance
- On an uncached Overpass failure, mapped installations keep their existing
+80
View File
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
const config = fs.readFileSync(new URL('../../vite.config.js', import.meta.url), 'utf8');
const start = config.indexOf(' async function refreshUpstream(key) {');
assert.notEqual(start, -1, 'FIRMS refresh function exists');
const end = config.indexOf('\n }', start);
assert.notEqual(end, -1, 'FIRMS refresh function closes');
const refreshSource = config.slice(start, end + 4);
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT'];
const NOW = Date.UTC(2026, 8, 11, 12);
const recent = { acqDate: '2026-09-11', acqTime: '1100' };
// Exercise the production refresh without opening a server or using a MAP_KEY.
// Inject only its upstream, clock and filter dependencies; keep its aggregation
// and source-status code intact, including failures while consuming records.
function createRefresh(fetchSource, filter = filterTrailing24h) {
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console',
`return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} });
}
test('FIRMS retains large sources in order and filters expired rows', async () => {
const header = 'latitude,longitude,acq_date,acq_time,confidence,frp\n';
const large = parseFirmsCsv(header + '1,2,2026-09-11,1100,n,4\n'.repeat(200_000));
const expired = { ...recent, acqDate: '2026-09-09' };
const last = { ...recent, marker: 'last' };
const calls = [];
let active = 0;
const refresh = createRefresh(async (key, source) => {
assert.equal(active++, 0, 'sources must be fetched sequentially');
calls.push(source);
await Promise.resolve();
active--;
return source === SOURCES[0] ? [...large, expired] : source === SOURCES[1] ? [last] : [];
});
const result = await refresh('fixture');
assert.deepEqual(calls, SOURCES);
assert.equal(result.fires.length, 200_001);
assert.equal(result.fires[0], large[0]);
assert.equal(result.fires[199_999], large.at(-1));
assert.equal(result.fires.at(-1), last);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: [200_000, 1, 0][index], ok: true,
})));
});
test('FIRMS keeps successful sources when another upstream fails', async () => {
const result = await createRefresh(async (key, source) => {
if (source === SOURCES[1]) throw new Error('upstream unavailable');
return [recent];
})('fixture');
assert.equal(result.fires.length, 2);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 1 ? 0 : 1, ok: index !== 1,
})));
});
test('FIRMS reports one failure if consuming a source throws before append', async () => {
const failing = [];
const result = await createRefresh(async (key, source) => source === SOURCES[0] ? failing : [recent],
(records, now) => {
if (records !== failing) return filterTrailing24h(records, now);
// Fault injection for aggregation; ordinary parsed CSV returns an array.
return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } };
})('fixture');
assert.equal(result.fires.length, 2);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 0 ? 0 : 1, ok: index !== 0,
})));
});
test('FIRMS distinguishes all-source failure from successful empty sources', async () => {
await assert.rejects(createRefresh(async () => { throw new Error('upstream unavailable'); })('fixture'),
/all FIRMS sources failed/);
const result = await createRefresh(async () => [])('fixture');
assert.deepEqual(result.fires, []);
assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
});
+1 -1
View File
@@ -2094,11 +2094,11 @@ function firmsProxy() {
for (const source of SOURCES) {
try {
const records = filterTrailing24h(await fetchSource(key, source), now);
sources.push({ source, count: records.length, ok: true });
// NOT fires.push(...records): spread passes each record as an argument,
// and a world/2 VIIRS pull exceeds V8's argument limit (~125k) at
// ~131k records — RangeError, and the whole source is silently dropped.
for (const record of records) fires.push(record);
sources.push({ source, count: records.length, ok: true });
} catch (err) {
console.warn(`[firms-proxy] ${source} fetch failed:`, err?.message || err);
sources.push({ source, count: 0, ok: false });