diff --git a/changelog.d/1244-terrain-test-cache-isolation.md b/changelog.d/1244-terrain-test-cache-isolation.md new file mode 100644 index 000000000..5efacd3bb --- /dev/null +++ b/changelog.d/1244-terrain-test-cache-isolation.md @@ -0,0 +1,4 @@ +- The terrain-heights bounds tests no longer read the checkout's + `.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so + `npm test` no longer fails after the dev server has cached terrain near + Austin (KnottyDyes, #1244). diff --git a/changelog.d/1303-wind-stale.md b/changelog.d/1303-wind-stale.md new file mode 100644 index 000000000..cb54224a5 --- /dev/null +++ b/changelog.d/1303-wind-stale.md @@ -0,0 +1,2 @@ +- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a + cached model run (quanticsoul4772, #1303). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0acd7575a..c8507dd72 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6018,7 +6018,7 @@ closed. Performance and live agency reliability require recorded validation. ## Wind forecast models -Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. +Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. Last-good data is marked stale; the wind row shows it, and `get_wind` returns `stale` and adds "(forecast may be stale)" to its summary. ### Local Weather review candidate diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs index 3746f4fe4..1676c80ba 100644 --- a/src/terrainHeightsBounds.test.mjs +++ b/src/terrainHeightsBounds.test.mjs @@ -99,6 +99,18 @@ function stubTerrainUpstream(t) { return asked; } +/** + * A cache file of the test's own, removed when the test ends. Without one the + * proxy falls back to this checkout's `.gev-cache/terrain-heights.json`, and a + * point the app has already cached there would answer a request the test + * expects to reach the stubbed upstream (#1242). + */ +async function isolatedCachePath(t) { + const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return path.join(dir, 'terrain-heights.json'); +} + test('a coordinate off the globe is not a cache key', () => { // Finite but not on Earth — the old guard accepted every one of these. for (const raw of [ @@ -122,7 +134,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache', upstreamCalls += 1; return new Response('[]', { status: 200 }); }); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const refused = await request(route, { url: '/?points=0,91' }); assert.equal(refused.statusCode, 400); @@ -135,7 +149,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache', test('one out-of-range point rejects the batch it is hidden in', async (t) => { stubFetch(t, async () => new Response('[]', { status: 200 })); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const mixed = await request(route, { url: '/?points=-97.74,30.26;0,999;-97.75,30.27', }); @@ -145,9 +161,12 @@ test('one out-of-range point rejects the batch it is hidden in', async (t) => { test('the point cache stops growing, and an evicted point is simply refetched', async (t) => { const asked = stubTerrainUpstream(t); // A ceiling of 3 exercises the same code path 50,000 does, in four requests. - const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get( - '/api/terrain/heights', - ); + const route = install( + terrainHeightsProxy({ + cachePath: await isolatedCachePath(t), + cacheMaxEntries: 3, + }), + ).get('/api/terrain/heights'); const point = (i) => `-97.7${i}000,30.26000`; for (let i = 1; i <= 4; i += 1) @@ -168,9 +187,7 @@ test('the point cache stops growing, and an evicted point is simply refetched', test('an over-large cache file is not read back into memory', async (t) => { const asked = stubTerrainUpstream(t); const point = '-97.74000,30.26000'; - const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); - const cachePath = path.join(dir, 'terrain-heights.json'); - t.after(() => rm(dir, { recursive: true, force: true })); + const cachePath = await isolatedCachePath(t); // A cache file holding the answer, but larger than the ceiling allows. const padded = { @@ -201,7 +218,9 @@ test('an over-large cache file is not read back into memory', async (t) => { test('the route refuses a flood of requests from one client', async (t) => { stubTerrainUpstream(t); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); let refusedAt = 0; for (let i = 1; i <= 95; i += 1) { @@ -218,7 +237,9 @@ test('the route refuses a flood of requests from one client', async (t) => { test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => { const asked = stubTerrainUpstream(t); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const host = 'localhost:5173'; const refused = [ { host, 'sec-fetch-site': 'cross-site' }, // from any web page @@ -253,7 +274,9 @@ test('a cross-site page cannot fill the cache or spend the visitor quota', async test('the app served behind a reverse proxy still gets terrain', async (t) => { const asked = stubTerrainUpstream(t); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const host = 'globe.example.lan'; // What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The // cost-bearing routes refuse these; terrain is keyless and needed to render, @@ -347,7 +370,9 @@ test('a second client keeps its own quota', async (t) => { stubFetch(t, async () => Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), ); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); for (let i = 0; i < 90; i += 1) await request(route, { diff --git a/src/tools/queries/atmosphere.js b/src/tools/queries/atmosphere.js index d20a58027..9cbd8fec0 100644 --- a/src/tools/queries/atmosphere.js +++ b/src/tools/queries/atmosphere.js @@ -204,10 +204,14 @@ export const getWind = defineTool({ const speed = windSpeed(vector.u, vector.v); const from = windFrom(vector.u, vector.v); const kph = speed * 3.6; + // When the model upstream fails, the proxy keeps serving the last run it + // has, however old, marked stale. + const stale = snapshot.stale === true; return { summary: `Wind at ${point.label}: ${kph.toFixed(0)} km/h` + - (from === 'Calm' ? ', calm.' : ` from the ${from}.`), + (from === 'Calm' ? ', calm' : ` from the ${from}`) + + (stale ? ' (forecast may be stale).' : '.'), data: { view: suggestView(services, { point, @@ -224,6 +228,7 @@ export const getWind = defineTool({ level: snapshot.level ?? '10 m above ground', model, cycle: snapshot.cycle ?? null, + stale, }, }; }, diff --git a/src/tools/queries/atmosphere.test.mjs b/src/tools/queries/atmosphere.test.mjs index cf19d83f3..57b41b81a 100644 --- a/src/tools/queries/atmosphere.test.mjs +++ b/src/tools/queries/atmosphere.test.mjs @@ -148,6 +148,34 @@ test('wind is sampled from the model grid at the location', async () => { const calm = await catalog.call('get_wind', { location: { lat: 0, lon: 0 } }); assert.equal(calm.summary, 'Wind at 0.0000, 0.0000: 0 km/h, calm.'); assert.equal(calm.data.calm, true); + assert.equal(calm.data.stale, false); + // The proxy's last run, served while the model upstream fails. + const cached = composeCatalog({ + tools: coreTools, + services: { + wind: { + getSnapshot: async (options) => ({ + ...(await wind.getSnapshot(options)), + stale: true, + reason: 'Wind upstream unavailable', + }), + }, + }, + }); + const stale = await cached.call('get_wind', { location: { lat: 0, lon: 0 } }); + assert.equal( + stale.summary, + 'Wind at 0.0000, 0.0000: 0 km/h, calm (forecast may be stale).', + ); + assert.equal(stale.data.stale, true); + u.fill(10); + const staleWest = await cached.call('get_wind', { + location: { lat: 30.27, lon: -97.74 }, + }); + assert.equal( + staleWest.summary, + 'Wind at 30.2700, -97.7400: 36 km/h from the W (forecast may be stale).', + ); const down = composeCatalog({ tools: coreTools, services: { wind: { getSnapshot: async () => ({ unavailable: true }) } },