This commit is contained in:
2026-09-08 15:40:48 +05:30
parent 024fc4adfd
commit 59828811f9
12 changed files with 437 additions and 71 deletions

View File

@@ -10,6 +10,7 @@ import {
isAwaitingReview,
isDismissed,
isSettled,
pollDelayFor,
currentStage,
isStuckOnMissingRunner,
productsOf,
@@ -334,3 +335,36 @@ test('the run id is on the drop, and gone from the run it points to', () => {
// cannot be written using the run's.
assert.notEqual(drop.batch_id, run.batch_id);
});
/* ── How often to look, and when to stop looking ──────────────────────────── */
/*
The console used to stop polling the moment a drop went to review, on the
reasoning that waiting for an admin is not progress. It is not — but the release
IS, and stopping there meant the panel said "waiting for review" until somebody
reloaded the page. A step-by-step panel that only advances on reload is the
thing the panel exists to replace.
*/
test('a review hold is polled slowly, not abandoned', () => {
assert.equal(isAwaitingReview(held), true);
assert.equal(pollDelayFor(held), 15000, 'a hold can last hours; 2s would be 1,800 reads an hour');
});
test('a running batch is polled at a pace a person can watch', () => {
const running = { ...held, status: 'running' } satisfies IngestBatch;
assert.equal(isAwaitingReview(running), false);
assert.equal(pollDelayFor(running), 2000);
});
// A released drop is no longer waiting on anybody, so it goes back to the fast
// cadence even though its own status still reads "pending".
test('a released drop is followed at the running pace', () => {
const released = {
...held,
files: [{ index: 0, filename: 'qa.csv', status: 'queued' as const, released_to: 'run-77' }],
} satisfies IngestBatch;
assert.equal(releasedRunId(released), 'run-77');
assert.equal(isAwaitingReview(released), false);
assert.equal(pollDelayFor(released), 2000);
});

View File

@@ -518,13 +518,72 @@ export function isSettled(batch: IngestBatch): boolean {
);
}
/** Sleeps, unless the caller aborts first. */
function wait(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(finish, ms);
function finish() {
clearTimeout(timer);
signal?.removeEventListener('abort', finish);
resolve();
}
signal?.addEventListener('abort', finish, { once: true });
});
}
/** Resolves the moment the tab is visible again — immediately if it already is. */
function whenVisible(signal?: AbortSignal): Promise<void> {
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
return Promise.resolve();
}
return new Promise((resolve) => {
const finish = () => {
if (document.visibilityState !== 'visible' && !signal?.aborted) return;
document.removeEventListener('visibilitychange', finish);
signal?.removeEventListener('abort', finish);
resolve();
};
document.addEventListener('visibilitychange', finish);
signal?.addEventListener('abort', finish, { once: true });
});
}
/** How long to wait before the next reading, given where the batch has got to. */
export function pollDelayFor(batch: IngestBatch): number {
// A run is minutes and a person is watching the stage name move.
if (!isAwaitingReview(batch)) return 2000;
// A review hold is however long their admin takes — sometimes hours. Two
// seconds against that is 1,800 requests an hour to be told "still waiting".
return 15000;
}
/**
* Polls until the batch settles.
* Polls until the batch is finished, THROUGH the review hold.
*
* Every two seconds. The pipeline's own stages take far longer than that, and a
* person is watching a progress bar — a slower cadence buys nothing but a
* screen that looks stuck. `onTick` fires on each reading so the caller can
* render the stage name and row counts as they move.
* ── Why it no longer stops at "awaiting review" ─────────────────────────────
*
* It used to return there, on the reasoning that waiting for an admin is not
* progress. True, but it left the console showing "waiting for review" forever
* once that admin released the drop — the steps only moved when the operator
* reloaded the page, which is the one thing a step-by-step progress panel is
* supposed to save them from. The release is exactly the transition worth
* watching: it is when the drop becomes a run and the products start arriving.
*
* So the hold is polled too, at `pollDelayFor`'s slower cadence — 15s rather
* than 2s, because a hold can last hours and a person is not watching a bar
* during one.
*
* ── And why a hidden tab costs nothing ──────────────────────────────────────
*
* Polling pauses entirely while the tab is in the background and takes a
* reading the instant it comes forward. So a sheet left open in another tab all
* afternoon makes no requests, and is up to date by the time the operator has
* looked at it — which is the same thing they used to get from reloading, minus
* the reload.
*
* `onTick` fires on each reading so the caller renders stage names and row
* counts as they move. It stops on a result and on a dismissal: a declined drop
* will never produce one.
*/
export async function pollBatch(
batchId: string,
@@ -539,13 +598,10 @@ export async function pollBatch(
// "released" forever.
const batch = await resolveBatch(await fetchBatch(batchId, signal), signal);
onTick(batch);
// Stops on a review hold and on a dismissal as well as on a result. Waiting
// for an admin is not progress, a declined drop will never produce one, and
// a browser tab cannot outlast either — the drop id is what the operator
// comes back with.
if (isSettled(batch) || isAwaitingReview(batch) || isDismissed(batch)) return batch;
if (isSettled(batch) || isDismissed(batch)) return batch;
await new Promise((resolve) => setTimeout(resolve, 2000));
await wait(pollDelayFor(batch), signal);
await whenVisible(signal);
}
}