perf: dedupe the overview poll and index the jobs rollup
Measured on the deployed console: the page shell and every module land in 39ms, so the 1.4s was entirely /admin/api/ops/overview, fetched twice. Twice because the sidebar and the overview view each called usePoll on the same url, each with its own timer. usePoll now keeps one store per url, so any number of subscribers share a single request and a single interval, and a request already on the wire is joined rather than duplicated. The endpoint itself was dominated by the jobs rollup: a full scan of autonomy_jobs, 521k rows and growing about nine thousand a day. A covering index on (job_type, lane, status, created_at) takes it from 548ms to 110ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnNxwxfXSbeNtjvtz5gayb
This commit is contained in:
@@ -46,40 +46,65 @@ export async function api(path, options = {}) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Poll without the spinner flash: keep showing the previous payload while the next
|
||||
// one is in flight, and never let a slow response overwrite a newer one.
|
||||
export function usePoll(path, intervalMs = 5000) {
|
||||
const [state, setState] = useState({ data: null, error: null, loading: true, at: null });
|
||||
const seq = useRef(0);
|
||||
const alive = useRef(true);
|
||||
// One store per url, shared by every component asking for it. The sidebar and the
|
||||
// overview both want /ops/overview, and without this they each opened their own
|
||||
// request on their own timer -- the same payload fetched twice, forever.
|
||||
const stores = new Map();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const mine = ++seq.current;
|
||||
try {
|
||||
const data = await api(path);
|
||||
if (!alive.current || mine !== seq.current) return;
|
||||
setState({ data, error: null, loading: false, at: Date.now() });
|
||||
} catch (error) {
|
||||
if (!alive.current || mine !== seq.current) return;
|
||||
function storeFor(path) {
|
||||
if (stores.has(path)) return stores.get(path);
|
||||
const store = {
|
||||
state: { data: null, error: null, loading: true, at: null },
|
||||
listeners: new Set(),
|
||||
inflight: null,
|
||||
timer: null,
|
||||
emit() { for (const fn of this.listeners) fn(this.state); },
|
||||
async load() {
|
||||
// a request already on the wire is shared rather than duplicated
|
||||
if (this.inflight) return this.inflight;
|
||||
this.inflight = api(path)
|
||||
.then((data) => { this.state = { data, error: null, loading: false, at: Date.now() }; })
|
||||
.catch((error) => {
|
||||
console.error('[ops] poll failed for', path, error.message);
|
||||
setState((prev) => ({ ...prev, error: error.message, loading: false }));
|
||||
this.state = { ...this.state, error: error.message, loading: false };
|
||||
})
|
||||
.finally(() => { this.inflight = null; this.emit(); });
|
||||
return this.inflight;
|
||||
},
|
||||
};
|
||||
stores.set(path, store);
|
||||
return store;
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
export function usePoll(path, intervalMs = 5000) {
|
||||
const store = storeFor(path);
|
||||
const [state, setState] = useState(store.state);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
refresh();
|
||||
const timer = setInterval(refresh, intervalMs);
|
||||
const onVisible = () => { if (!document.hidden) refresh(); };
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => {
|
||||
alive.current = false;
|
||||
clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [refresh, intervalMs]);
|
||||
const listener = (next) => setState(next);
|
||||
store.listeners.add(listener);
|
||||
setState(store.state);
|
||||
store.load();
|
||||
|
||||
return { ...state, refresh };
|
||||
// the interval belongs to the store, not the component, so N subscribers still
|
||||
// produce exactly one request per tick
|
||||
if (!store.timer) {
|
||||
store.timer = setInterval(() => { if (!document.hidden) store.load(); }, intervalMs);
|
||||
}
|
||||
const onVisible = () => { if (!document.hidden) store.load(); };
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
|
||||
return () => {
|
||||
store.listeners.delete(listener);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
if (!store.listeners.size && store.timer) {
|
||||
clearInterval(store.timer);
|
||||
store.timer = null;
|
||||
}
|
||||
};
|
||||
}, [store, intervalMs]);
|
||||
|
||||
return { ...state, refresh: () => store.load() };
|
||||
}
|
||||
|
||||
export function useHashRoute(fallback) {
|
||||
|
||||
@@ -244,6 +244,12 @@ function initAutonomySchema(db) {
|
||||
'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN replay_run_id INTEGER',
|
||||
'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN distinct_instruments INTEGER',
|
||||
'ALTER TABLE autonomy_calibration_snapshots ADD COLUMN top_instrument_share REAL',
|
||||
// The dashboard groups jobs by type/lane/status on every poll. autonomy_jobs is
|
||||
// half a million rows and grows by roughly nine thousand a day from the archive
|
||||
// reconcile loop, so without this that one query was 548ms and rising.
|
||||
// created_at is included to keep the MAX() index-only.
|
||||
'CREATE INDEX IF NOT EXISTS idx_autonomy_jobs_group ON autonomy_jobs(job_type, lane, status, created_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_calibration_snapshot_latest ON autonomy_calibration_snapshots(cohort_key, source, id)',
|
||||
]) {
|
||||
try {
|
||||
db.exec(statement);
|
||||
|
||||
Reference in New Issue
Block a user