Skip to content

fix(downloads): make retry-import actually requeue the import - #917

Open
m4bard wants to merge 1 commit into
Listenarrs:canaryfrom
m4bard:fix/retry-import-requeues
Open

fix(downloads): make retry-import actually requeue the import#917
m4bard wants to merge 1 commit into
Listenarrs:canaryfrom
m4bard:fix/retry-import-requeues

Conversation

@m4bard

@m4bard m4bard commented Aug 31, 2026

Copy link
Copy Markdown

POST /api/v1/downloads/{id}/retry-import answers 200 with "Import retry queued" and logs Reset blocked import ... back to ImportPending. It queues nothing.

I hit this trying to retry a blocked import. The call succeeded and the status changed, but no DownloadProcessingJobs row ever appeared for that download. I gave it fifteen minutes, then restarted the container in case a fresh processor would pick it up, and it stayed where it was.

Why

RetryBlockedImport calls download.Unblock() and saves the row. That clears ImportBlockReason and sets the status to ImportPending. Nothing watches either field.

The only thing that imports a download is a DownloadProcessingJob, and two places in production create one: DirectDownloadProcessor for DDL, and DownloadMonitorService.OnDownloadCompleted. The second is reached from TriggerCallbacks, which switches on (previous.Status, current.Status) and returns as soon as they match:

case var (old, next) when old == next:
    return;

So the enqueue is edge-triggered on the download client reporting completion. For an import that is already blocked, that edge is in the past and will not come round again. The row sits in ImportPending while the endpoint reports success.

The button

The Retry button in the downloads view is the same feature approached from the other side:

const retryDownload = async () => {
  toast.info('Coming Soon', 'Retry functionality will be implemented soon')
}

It never called the endpoint. It also rendered on download.status === 'Failed', and the endpoint answers 400 to anything that is not ImportBlocked, so pointing the stub at the endpoint would not have been enough by itself. The button could not appear for a state the endpoint accepts.

Between the two, there is currently no working way to retry a blocked import by any route. #890 is someone sitting in that state.

What this changes

Adds IDownloadProcessingJobService.RequeueAsync. I made it separate from EnqueueAsync rather than loosening that one, because they are driven by different things: EnqueueAsync runs off the client reporting completion and so requires the download to be Completed, while a retry arrives later, once the download is already ImportPending.

RequeueAsync returns an existing active job if there is one. Failing that it reuses the download's newest job, and failing that it creates a fresh one, since retention deletes terminal jobs after a week. Reusing keeps the processing log, which is the only record of why the earlier attempts failed. It does reset the retry budget, on the grounds that someone asking for another attempt has usually changed something the job depends on. The recent-completion cooldown that EnqueueAsync applies is deliberately left off, since silently doing nothing is the behaviour being fixed.

The controller queues before it persists the unblock. If the queue call fails, the download stays blocked rather than landing in a state that reads as retrying and never is.

The button now calls the endpoint, and is offered on ImportBlocked.

Tests

RetryBlockedImport_ImportBlocked_TransitionsToImportPending asserted the status transition and nothing else, so it passed against an endpoint that did no work. I left it alone and added a second test beside it that asserts a job is actually queued.

Five tests added on the backend and two on the frontend. I checked that each one is capable of failing, by reverting the production changes one at a time: the controller requeue, the job reuse, the retry-budget reset, and both halves of the frontend change. Each time the matching test failed and the rest stayed green.

Full suite passes.


Worked through with Claude Code at my direction. The claims above were checked by running them rather than by reading, and I reviewed this before posting.

@m4bard
m4bard requested a review from a team August 31, 2026 22:46
@m4bard
m4bard force-pushed the fix/retry-import-requeues branch from 9ff711e to eb553b9 Compare September 1, 2026 14:51
@m4bard

m4bard commented Sep 1, 2026

Copy link
Copy Markdown
Author

Amended and force pushed, now eb553b9a. One addition, and it is worth explaining rather than leaving in the diff.

retry-import now also accepts a download sitting in ImportPending when nothing has an active job for it.

That state is this endpoint's own doing. Before the change in this PR it cleared the block and set ImportPending, then queued nothing, so every call left a download that no job will ever pick up. Gating strictly on ImportBlocked, which is what I had, refuses exactly those downloads and leaves them stuck for good.

They are also worse off than blocked ones, which I had not appreciated when I opened this:

  • AutomaticSearchService.cs:156 counts ImportPending as an active download and skips the audiobook.
  • DownloadDuplicateGuard.cs:39 does the same.
  • Neither mentions ImportBlocked.

So a download the old endpoint touched is not merely unimportable. The book stops being searched as well, silently, because something looks like it is already downloading it. A blocked download at least leaves the book eligible.

The active job check is what keeps this narrow. An import genuinely in flight has a pending or processing job and is still refused, so this is not "accept anything that is not blocked". Two tests cover it, and the second is there so the pair cannot both pass against a version that simply accepted every ImportPending.

Nothing else in the PR changed.


Worked through with Claude Code at my direction. The claims above were checked by running them rather than by reading, and I reviewed this before posting.

POST /api/v1/downloads/{id}/retry-import returned 200 with "Import retry
queued" and logged "Reset blocked import ... back to ImportPending", but
queued nothing. It called download.Unblock() and persisted the row. That
clears ImportBlockReason and sets the status to ImportPending, and nothing
anywhere watches either field.

The only thing that imports a download is a DownloadProcessingJob, and in
production only two places create one: DirectDownloadProcessor, for DDL, and
DownloadMonitorService.OnDownloadCompleted. The second is reached from
TriggerCallbacks, which switches on (previous.Status, current.Status) and
returns immediately when they are equal. It is edge-triggered on the download
client reporting completion. For a blocked import that edge is in the past and
will not happen again, so the download sat in ImportPending forever while the
endpoint reported success.

The Retry button in the downloads view was a stub on the same feature:

    const retryDownload = async () => {
      toast.info('Coming Soon', 'Retry functionality will be implemented soon')
    }

It never called retry-import, and it rendered on status === 'Failed', which the
endpoint rejects with 400 because it requires ImportBlocked. Wiring the stub
straight to the endpoint would not have helped; the button could not appear for
a state the endpoint accepts.

Adds IDownloadProcessingJobService.RequeueAsync. It is separate from
EnqueueAsync because that one is driven by the client reporting completion and
so requires the download to be Completed, while a retry arrives later, once the
download is already ImportPending. Requeue reuses the download's newest job so
its processing log survives, which is the record of why the earlier attempts
failed, and resets the retry budget because an operator asking for another
attempt has usually changed something the job depends on. If retention has
already deleted the job, a fresh one is created.

The controller queues before it persists the unblock, so a failure leaves the
download blocked rather than in a new silent limbo.

The existing controller test asserted the status transition only, so it passed
against an endpoint that did nothing. It stays as it is and a second test now
asserts that a job is actually queued.

Verified by reverting each production change in turn and confirming the matching
test fails: the controller requeue, the job reuse, the retry-budget reset, and
both halves of the frontend change.

It also accepts a download already stranded in ImportPending with no active job.
Before this endpoint queued anything it still cleared the block and set
ImportPending, so anyone who called the old version is left with downloads that
nothing will ever pick up. Those are worse off than blocked ones:
AutomaticSearchService and DownloadDuplicateGuard both count ImportPending as an
active download and skip the book, while neither counts ImportBlocked, so the
book is never re-searched either. Refusing them would leave every existing
victim stranded for good.

The active-job check is what keeps that narrow. An import genuinely in flight
has a pending or processing job and is still refused, so this is not "accept
anything that is not blocked".

It also refuses, with a conflict rather than a constraint violation, when another
download for the same audiobook is already active. EfDownloadRepository derives
ActiveAudiobookDeduplicationKey from the audiobook id for active statuses and a
filtered unique index enforces one at a time. ImportPending is active and
ImportBlocked is not, so unblocking collided with any live sibling and surfaced
as a SQLite constraint violation from inside SaveChanges, which tells the caller
nothing about what to do.

A terminal sibling holds no key and does not block the retry, which has its own
test: without it the check would refuse every book that had ever failed twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEVQ7qDJLk5196MFeggWuA
@m4bard
m4bard force-pushed the fix/retry-import-requeues branch from eb553b9 to 1b24f67 Compare September 1, 2026 20:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant