{"slug": "a-review-still-marked-processing-after-two-minutes-was-killed-sixty-seconds-in", "title": "A review still marked \"processing\" after two minutes was killed sixty seconds in", "summary": "A developer building an assessment platform fixed a stuck-state bug where AI reviews of written answers, run inline in a serverless route with a 60-second maxDuration, could be killed by the platform timeout and leave the attempt row permanently marked \"processing,\" producing an endless spinner. The fix derives the UI status from both the stored status and the row's updated_at, treating any pending or processing review older than two minutes as failed, and mounts a client refresher that polls router.refresh() every five seconds only while the derived status is processing.", "body_md": "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:\n\n``` js\nexport const maxDuration = 60;\n```\n\nSixty 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.\n\nThen we gave saved attempts their own page, and the simple design grew a hole.\n\nThe 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.\n\nNow 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.\n\nThis 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.\n\nThe 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:\n\n```\n/**\n * The attempts route scores written answers inside its own 60 second budget. A\n * review still marked in progress well after that was cut off and will never\n * finish, so it reads as failed rather than as a spinner that never stops.\n */\nconst REVIEW_STALE_MS = 2 * 60 * 1000;\n\nfunction toPanelStatus(status: string, updatedAt: Date): AiPanelStatus {\n  if (status === 'complete' || status === 'failed') return status;\n  if (status === 'pending' || status === 'processing') {\n    return Date.now() - updatedAt.getTime() < REVIEW_STALE_MS\n      ? 'processing'\n      : 'failed';\n  }\n  return 'not_applicable';\n}\n```\n\nTwo 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:\n\n`updated_at` is set when the row is written, which is before the review starts, not when it ends.\nNote 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.\n\nThe other half is how feedback that does arrive gets onto a server-rendered page. The page mounts a component with no markup in it:\n\n``` js\nexport function ExerciseReviewRefresher() {\n  const router = useRouter();\n  useEffect(() => {\n    const id = setInterval(() => router.refresh(), 5000);\n    return () => clearInterval(id);\n  }, [router]);\n  return null;\n}\n```\n\nThree things make that safe rather than a permanent five second heartbeat against our own database:\n\n`processing`. A complete or failed report does not mount it at all.` 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.`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.\nA 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.\n\nThe written exercise format these reviews belong to is described on [the written exercise guide](https://cogniprep.app/assessment-centre-practice/written-exercise), which is free to read, and the hub at [cogniprep.app/assessment-centre-practice](https://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.\n\nThe 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.\n\nAny 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.", "url": "https://wpnews.pro/news/a-review-still-marked-processing-after-two-minutes-was-killed-sixty-seconds-in", "canonical_source": "https://dev.to/daniel_pertu/a-review-still-marked-processing-after-two-minutes-was-killed-sixty-seconds-in-dhf", "published_at": "2026-10-15 08:11:52+00:00", "updated_at": "2026-10-15 08:16:08.607369+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "mlops"], "entities": [], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-review-still-marked-processing-after-two-minutes-was-killed-sixty-seconds-in", "markdown": "https://wpnews.pro/news/a-review-still-marked-processing-after-two-minutes-was-killed-sixty-seconds-in.md", "text": "https://wpnews.pro/news/a-review-still-marked-processing-after-two-minutes-was-killed-sixty-seconds-in.txt", "jsonld": "https://wpnews.pro/news/a-review-still-marked-processing-after-two-minutes-was-killed-sixty-seconds-in.jsonld"}}