Keep ingestion pipeline alive across transient database failures#155
Merged
Conversation
TaskProcessingHostedService.ExecuteAsync wrapped the whole while loop in a single try/catch, so a transient exception from GetNew() (for example the Postgres server restarting during maintenance) exited the loop and stopped the background service permanently while the web host kept serving. The pod stayed Ready, so nothing restarted it and ingestion silently stopped. Move the per-iteration work into RunOnceAsync with its own try/catch, so a bad tick is logged and the loop keeps polling and recovers on the next tick. This mirrors how IngestionSchedulerHostedService already handles it. ResetUnfinishedTasks only re-queued InProgress tasks with IsRetriable=true. A non-retriable task left InProgress by such an interruption stayed stuck forever and deadlocked the scheduler: GetStale().Take(N) keeps selecting the same stalest data sources, sees their orphaned "active" task and skips them, so no data source is ever synced again. Reset every InProgress task at startup regardless of IsRetriable, since an InProgress task at startup was by definition interrupted and must be re-queued.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What happened
On a production deployment the whole ingestion pipeline silently stopped for ~3 months. No data source was synced and newly added ones never ingested, but the API pod stayed
Running/Readythe entire time, so nothing restarted it and no alert fired. It was only noticed when a customer reported the chatbot couldn't find documents they had uploaded.Root cause was a transient database blip — the Azure PostgreSQL server restarting for scheduled maintenance (
57P03: the database system is shutting down). That is a normal, expected event, but the way two pieces of code handle it turned it into a permanent outage.Two defects
1.
TaskProcessingHostedServicedies permanently on a transient error.The polling body (
GetNew()and the dispatch loop) lived directly inside thewhileloop, and the onlytry/catchwrapped the entire loop. So whenGetNew()threw during the DB restart, the exception left the loop,"TaskProcessingHostedService crashed."was logged once, andExecuteAsyncreturned — the background service was gone for good. The web host kept running, so the pod stayed healthy and Kubernetes never restarted it.IngestionSchedulerHostedServicealready does this correctly (itstry/catchis inside the loop viaRunOnceAsync), which is why the scheduler survived and only the task processor died.2. Interrupted tasks are never recovered, which deadlocks the scheduler.
When the processor died it left the two tasks it was mid-processing stuck in
InProgress. On startupResetUnfinishedTasksonly re-queuesInProgresstasks withIsRetriable = true, so these non-retriable ones stayedInProgressforever. The scheduler then permanently skipped them:GetStale().Take(N)always returns the same stalest data sources, sees their orphaned "active" task andcontinues past them, so it never schedules anything. This means even restarting the pod would not have recovered the fleet — the stuck tasks had to be cleared by hand.The fix
TaskProcessingHostedService: move the per-iteration work intoRunOnceAsyncwith its owntry/catch. A bad tick is now logged ("...iteration error.") and the loop keeps polling, recovering on the next 5-second tick. Same shape asIngestionSchedulerHostedService.TaskProcessor.ResetUnfinishedTasks: reset everyInProgresstask on startup regardless ofIsRetriable. A task stillInProgressat startup was interrupted by definition (the worker that owned it is gone) and must be re-queued.Together: defect 1 means a maintenance restart no longer kills the processor, and defect 2 means anything left mid-flight is picked back up instead of wedging the scheduler.
Verification
Reproduced and confirmed on the affected deployment: a
Newtask had sat unprocessed for 7 days (proving the poll loop was dead, not just idle); restarting the pod revived it instantly, and after clearing the two stuckInProgresstasks the scheduler immediately resumed creating sync tasks.dotnet build -c Releasepasses (0 errors). The repo has no test project, so no automated test is added.Suggested follow-ups (not in this PR)
EnableRetryOnFailure(EF Core connection resiliency) so transient DB errors are retried transparently.last_syncis older than a threshold.