DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A review still marked "processing" after two minutes was killed sixty seconds in

Our assessment centre exercises have written answers in them, and those get an AI review. The review runs inline in the request that saves the attempt, not in a worker, which means one route handler holds the whole thing:

export const maxDuration = 60;
Enter fullscreen mode Exit fullscreen mode

Sixty seconds, on a serverless function. That is a deliberate number: the review usually finishes in a few seconds, and the request returning with the feedback already attached is much simpler than a queue, a job id and a client that polls. The attempt row carries an ai_status so the UI knows which of the four states it is in: not applicable, processing, complete, failed.

Then we gave saved attempts their own page, and the simple design grew a hole.

Nobody writes "failed" when the writer is what died

The happy path is fine. The route sets processing, does the work, writes complete or failed, responds. The browser is still sitting there and sees the answer.

Now take the request that hits the ceiling. At sixty seconds the platform stops the function. There is no finally, no catch, no last write. The row stays exactly as the handler left it, which is processing, forever. A page that renders a spinner whenever the status says processing will render a spinner for that row in six months.

This class of bug is easy to miss because the state is correct in the ordinary sense. The column is not corrupt. It faithfully records what we intended to do. It just has no way to record that the thing which intended it no longer exists.

The fix is a second deadline, and it is not sixty seconds

The saved report page does not trust the stored status on its own. It reads the status together with the row's updated_at and derives what the UI is allowed to show:

/**
 * The attempts route scores written answers inside its own 60 second budget. A
 * review still marked in progress well after that was cut off and will never
 * finish, so it reads as failed rather than as a spinner that never stops.
 */
const REVIEW_STALE_MS = 2 * 60 * 1000;

function toPanelStatus(status: string, updatedAt: Date): AiPanelStatus {
  if (status === 'complete' || status === 'failed') return status;
  if (status === 'pending' || status === 'processing') {
    return Date.now() - updatedAt.getTime() < REVIEW_STALE_MS
      ? 'processing'
      : 'failed';
  }
  return 'not_applicable';
}
Enter fullscreen mode Exit fullscreen mode

Two minutes, against a sixty second budget. The gap matters and it is worth being explicit about why it is not sixty seconds and one millisecond:

  • updated_at is set when the row is written, which is before the review starts, not when it ends.
  • Clock skew between the database and whichever region rendered the page is small but real.
  • The failure mode of being too eager is telling somebody their feedback failed while it is one second away from arriving. The failure mode of being too patient is two minutes of spinner on a page they can reload. The second one is cheaper, so the deadline gets slack.

Note also that the derived status is a function of two columns, not a new column. Nothing writes "stale". A background job that swept the table and rewrote abandoned rows would be a second source of truth and another thing to alert on. Deriving it means the rule lives in one function with the comment that explains it, and every surface that imports that function agrees.

Polling that is allowed to stop

The other half is how feedback that does arrive gets onto a server-rendered page. The page mounts a component with no markup in it:

export function ExerciseReviewRefresher() {
  const router = useRouter();
  useEffect(() => {
    const id = setInterval(() => router.refresh(), 5000);
    return () => clearInterval(id);
  }, [router]);
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Three things make that safe rather than a permanent five second heartbeat against our own database:

  1. The page only renders it when the derived status is processing. A complete or failed report does not mount it at all.
  2. router.refresh() re-runs the server component, and the page is force-dynamic with revalidate = 0, so the refresh actually re-reads the row instead of being served the render from five seconds ago.
  3. The staleness rule guarantees the loop terminates. Once two minutes have passed, the next refresh derives failed, the component unmounts, and the interval is cleared by its own cleanup. There is no counter to get wrong, because the thing that ends the poll is the same rule that ends the spinner.

A poller whose exit condition is a separate variable from the one the UI renders is a poller that will one day disagree with the UI. Here there is one condition.

See it

The written exercise format these reviews belong to is described on the written exercise guide, which is free to read, and the hub at cogniprep.app/assessment-centre-practice names the one e-tray scenario that is free to play in full. Play that and you can watch the inline review state in its ordinary form: the request returns with the feedback attached, so the processing state is brief and then the panel fills in.

The saved report page described here, with the refresher and the derived status, is in the repository and goes out with the next release rather than being clickable today. The part you can check now is the timing claim: submit a written answer and watch how long the request that saves the attempt takes in the Network tab. That single request is doing the review.

The general version

Any status column written by a process that can be killed needs a second opinion, and the second opinion has to be derived from data that is already there. Ask of every non-terminal state in a table: which process writes the terminal one, and what happens to the row if that process is gone? If the answer is "nothing", the row is a permanent spinner.

Top comments (0)