mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge remote-tracking branch 'origin/pr1234' into codex/cctv-shared-batch-20261019
This commit is contained in:
commit
7dfecd7d98
7 files changed
+148
-6
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
- Serve the camera catalog while it refreshes. An expired catalog made every
|
||||||
|
camera request wait for the slowest pack to reload; it is now answered at
|
||||||
|
once while one background refresh runs, so only the first load waits. Estonia's
|
||||||
|
slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out
|
||||||
|
on about a third of loads (quanticsoul4772, #1234).
|
||||||
@@ -4409,7 +4409,11 @@ silently demoting every later lookup for the session.
|
|||||||
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
|
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
|
||||||
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
|
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
|
||||||
across packs, so a lower global cap thins every region instead of starving the last pack.
|
across packs, so a lower global cap thins every region instead of starving the last pack.
|
||||||
~1,100 cameras total, all RAW PRIOR poses, stills-first.
|
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
|
||||||
|
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
|
||||||
|
pack, so only the very first load waits for the slowest pack (a live first load took about
|
||||||
|
22 s). Estonia's Tarktee DATEX feeds, which answer in 2 to 16.6 s, get 30 s
|
||||||
|
(`TARKTEE_FETCH_TIMEOUT_MS`) where other packs get 15 s.
|
||||||
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
|
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
|
||||||
validated 2026-07-21): the COVERAGE toggle is a
|
validated 2026-07-21): the COVERAGE toggle is a
|
||||||
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum
|
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum
|
||||||
|
|||||||
@@ -204,7 +204,8 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
|||||||
*
|
*
|
||||||
* Merges every source pack (live open-data packs, local file, env
|
* Merges every source pack (live open-data packs, local file, env
|
||||||
* variable), deduplicates by ID, shares the catalog cap fairly across
|
* variable), deduplicates by ID, shares the catalog cap fairly across
|
||||||
* packs, and caches for CCTV_SOURCE_CACHE_MS.
|
* packs, and caches for CCTV_SOURCE_CACHE_MS. Past that, the cached list is
|
||||||
|
* returned while a background refresh replaces it; only a first load waits.
|
||||||
*
|
*
|
||||||
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
||||||
*/
|
*/
|
||||||
@@ -219,11 +220,26 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
|||||||
// Single-flight: a burst of requests arriving past the TTL shares ONE refresh
|
// Single-flight: a burst of requests arriving past the TTL shares ONE refresh
|
||||||
// instead of each launching the full multi-provider refetch. The `.finally`
|
// instead of each launching the full multi-provider refetch. The `.finally`
|
||||||
// clears the ref so the next post-TTL cycle starts fresh.
|
// clears the ref so the next post-TTL cycle starts fresh.
|
||||||
if (_cctvSourceInflight) return _cctvSourceInflight;
|
if (!_cctvSourceInflight) {
|
||||||
_cctvSourceInflight = refreshCctvSources().finally(() => {
|
_cctvSourceInflight = refreshCctvSources().finally(() => {
|
||||||
_cctvSourceInflight = null;
|
_cctvSourceInflight = null;
|
||||||
});
|
});
|
||||||
return _cctvSourceInflight;
|
// A background refresh has no caller waiting on it: report its failure
|
||||||
|
// here, once per refresh rather than once per request it serves.
|
||||||
|
if (_cctvSourceCache.length) {
|
||||||
|
_cctvSourceInflight.catch((error) => {
|
||||||
|
console.warn(
|
||||||
|
'[CCTV] background catalog refresh failed:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// With a catalog in hand, answer with it while the refresh runs: the
|
||||||
|
// refresh lasts as long as the slowest pack (Tarktee's DATEX feeds take up
|
||||||
|
// to its 30 s timeout), and every /api/cctv request would otherwise wait
|
||||||
|
// for it. Only the first load, with nothing to serve yet, waits.
|
||||||
|
return _cctvSourceCache.length ? _cctvSourceCache : _cctvSourceInflight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -439,6 +439,10 @@ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
|
|||||||
* pending forever — a hung fetch aborts, the loader returns [], and
|
* pending forever — a hung fetch aborts, the loader returns [], and
|
||||||
* serve-stale/other packs take over. */
|
* serve-stale/other packs take over. */
|
||||||
export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000;
|
export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000;
|
||||||
|
/** Tarktee's two DATEX feeds (120 and 160 KB) answered in 2 to 16.6 s across
|
||||||
|
* ten requests on 2026-10-18, so the shared 15 s timeout dropped the Estonian
|
||||||
|
* pack on about a third of loads. Both must arrive for any camera to load. */
|
||||||
|
export const TARKTEE_FETCH_TIMEOUT_MS = 30 * 1000;
|
||||||
/** Individual CCTV image fetches must settle before the active 10-second
|
/** Individual CCTV image fetches must settle before the active 10-second
|
||||||
* client refresh cadence. A bounded miss can fall through to Street View or
|
* client refresh cadence. A bounded miss can fall through to Street View or
|
||||||
* the synthetic frame instead of leaving the browser preview pending. */
|
* the synthetic frame instead of leaving the browser preview pending. */
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import {
|
|||||||
TORONTO_GROUND_ELEVATION_M,
|
TORONTO_GROUND_ELEVATION_M,
|
||||||
TORONTO_MAX_CATALOG_BYTES,
|
TORONTO_MAX_CATALOG_BYTES,
|
||||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||||
|
TARKTEE_FETCH_TIMEOUT_MS,
|
||||||
DELDOT_CCTV_URL,
|
DELDOT_CCTV_URL,
|
||||||
DEFAULT_DELDOT_MAX_SOURCES,
|
DEFAULT_DELDOT_MAX_SOURCES,
|
||||||
DELDOT_ANCHORS,
|
DELDOT_ANCHORS,
|
||||||
@@ -1194,11 +1195,11 @@ export async function loadTarkteeSourcesFromDatex() {
|
|||||||
const [locResp, imgResp] = await Promise.all([
|
const [locResp, imgResp] = await Promise.all([
|
||||||
fetch(TARKTEE_LOCATIONS_URL, {
|
fetch(TARKTEE_LOCATIONS_URL, {
|
||||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||||
}),
|
}),
|
||||||
fetch(TARKTEE_IMAGES_URL, {
|
fetch(TARKTEE_IMAGES_URL, {
|
||||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
if (!locResp.ok) {
|
if (!locResp.ok) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '../../server/providers/cctv/cap.js';
|
} from '../../server/providers/cctv/cap.js';
|
||||||
import {
|
import {
|
||||||
CCTV_MAX_SOURCES_CEILING,
|
CCTV_MAX_SOURCES_CEILING,
|
||||||
|
CCTV_SOURCE_CACHE_MS,
|
||||||
DEFAULT_CCTV_MAX_SOURCES,
|
DEFAULT_CCTV_MAX_SOURCES,
|
||||||
} from '../../server/providers/cctv/constants.js';
|
} from '../../server/providers/cctv/constants.js';
|
||||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||||
@@ -160,3 +161,88 @@ test('a pack trimmed to its nearest cameras reports how many it offered', async
|
|||||||
assert.equal(kept.available, 5);
|
assert.equal(kept.available, 5);
|
||||||
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
|
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an expired catalog is served at once while a slow pack refreshes in the background', async (t) => {
|
||||||
|
const saved = { ...process.env };
|
||||||
|
const envSources = (prefix) =>
|
||||||
|
JSON.stringify(
|
||||||
|
Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
id: `${prefix}-${i}`,
|
||||||
|
name: `${prefix} ${i}`,
|
||||||
|
lat: 30 + i * 0.01,
|
||||||
|
lon: -97,
|
||||||
|
url: `https://example.test/${prefix}-${i}.jpg`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
// Live packs run alongside the env pack, so one slow upstream can hold
|
||||||
|
// the refresh open.
|
||||||
|
process.env.CCTV_FORCE_AUSTIN = '1';
|
||||||
|
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||||
|
process.env.CCTV_SOURCES_JSON = envSources('old');
|
||||||
|
let now = 1_000_000;
|
||||||
|
t.mock.method(Date, 'now', () => now);
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
const hung = [];
|
||||||
|
let hang = false;
|
||||||
|
t.mock.method(globalThis, 'fetch', () =>
|
||||||
|
hang
|
||||||
|
? new Promise((_, reject) => {
|
||||||
|
hung.push(() => reject(new Error('upstream gave up')));
|
||||||
|
})
|
||||||
|
: Promise.reject(new Error('offline')),
|
||||||
|
);
|
||||||
|
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||||
|
assert.deepEqual(
|
||||||
|
(await getSources()).map((s) => s.id),
|
||||||
|
['old-0', 'old-1', 'old-2'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past the TTL, with every live pack's upstream hanging.
|
||||||
|
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||||
|
hang = true;
|
||||||
|
process.env.CCTV_SOURCES_JSON = envSources('new');
|
||||||
|
const served = await Promise.race([
|
||||||
|
getSources(),
|
||||||
|
new Promise((resolve) => setTimeout(() => resolve('waited'), 200)),
|
||||||
|
]);
|
||||||
|
assert.notEqual(
|
||||||
|
served,
|
||||||
|
'waited',
|
||||||
|
'the request did not wait for the refresh',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
served.map((s) => s.id),
|
||||||
|
['old-0', 'old-1', 'old-2'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Requests during the refresh share it rather than starting another.
|
||||||
|
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
await pause();
|
||||||
|
const inFlight = hung.length;
|
||||||
|
assert.ok(inFlight > 0, 'the refresh reached the hanging upstreams');
|
||||||
|
await getSources();
|
||||||
|
await getSources();
|
||||||
|
await pause();
|
||||||
|
assert.equal(hung.length, inFlight, 'only one refresh ran');
|
||||||
|
|
||||||
|
// When the slow pack gives up, the refreshed catalog replaces the old one.
|
||||||
|
hang = false;
|
||||||
|
for (const giveUp of hung) giveUp();
|
||||||
|
for (let i = 0; i < 50; i += 1) {
|
||||||
|
const ids = (await getSources()).map((s) => s.id);
|
||||||
|
if (ids[0] === 'new-0') break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
(await getSources()).map((s) => s.id),
|
||||||
|
['new-0', 'new-1', 'new-2'],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in saved)) delete process.env[key];
|
||||||
|
}
|
||||||
|
Object.assign(process.env, saved);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,9 +2,11 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
loadTallinnSourcesFromCatalog,
|
loadTallinnSourcesFromCatalog,
|
||||||
|
loadTarkteeSourcesFromDatex,
|
||||||
parseTarkteeDatexImages,
|
parseTarkteeDatexImages,
|
||||||
parseTarkteeDatexLocations,
|
parseTarkteeDatexLocations,
|
||||||
} from '../../server/providers/cctv/sources.js';
|
} from '../../server/providers/cctv/sources.js';
|
||||||
|
import { TARKTEE_FETCH_TIMEOUT_MS } from '../../server/providers/cctv/constants.js';
|
||||||
|
|
||||||
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||||
@@ -84,3 +86,27 @@ test('Tarktee DATEX image parser rejects non-official hosts', () => {
|
|||||||
const images = parseTarkteeDatexImages(xml);
|
const images = parseTarkteeDatexImages(xml);
|
||||||
assert.equal(images.size, 0);
|
assert.equal(images.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', async (t) => {
|
||||||
|
const timeouts = [];
|
||||||
|
const timeout = AbortSignal.timeout;
|
||||||
|
t.mock.method(AbortSignal, 'timeout', (ms) => {
|
||||||
|
timeouts.push(ms);
|
||||||
|
return timeout.call(AbortSignal, ms);
|
||||||
|
});
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
t.mock.method(
|
||||||
|
globalThis,
|
||||||
|
'fetch',
|
||||||
|
async (url) =>
|
||||||
|
new Response(
|
||||||
|
String(url).includes('roadCameraLocations')
|
||||||
|
? SAMPLE_LOCATIONS_XML
|
||||||
|
: SAMPLE_IMAGES_XML,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cameras = await loadTarkteeSourcesFromDatex();
|
||||||
|
assert.deepEqual(timeouts, [30_000, 30_000]);
|
||||||
|
assert.equal(TARKTEE_FETCH_TIMEOUT_MS, 30_000);
|
||||||
|
assert.equal(cameras.length, 1);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user