Import the original book when its conversion fails (#1094)#1157
Conversation
main() imported the incoming book only inside `if convert_successful:` with no else, so every conversion failure returned without calling add_book_to_library() while the finally block deleted the source from the ingest folder. The book was simply absent, and the service logged "Successfully processed" on the way past. The two sibling branches that decline to convert (auto-convert off, format on the do-not-convert list) already import the original, so the fallback makes the behaviour consistent rather than introducing a new rule. The conversion_attempted guard is load-bearing: the ignore-list branch reports convert_successful=False having already imported, so a bare else would file every deliberately-unconverted book twice. Also from the same report: backup() logs its absolute destination and the safety-timeout branch names /config/processed_books/failed/<file>, and the service no longer claims the processor should have timed out internally -- ingest_timeout_minutes bounds only is_file_in_use(), so no such internal conversion limit exists.
The fallback rescues a real book whose conversion failed, but .acsm is an Adobe fulfillment ticket rather than an ebook. Importing it would file a junk library entry, and it would contradict the guidance CWA already prints for a failed .acsm, which tells the user the file was moved to processed_books/failed. _NOT_A_BOOK_FORMATS keeps that list in one place, and a test pins that every format on it also has guidance explaining why it was declined -- silently dropping a file the user dropped in is only acceptable if we say why.
The fallback added earlier could not help the reported case. The service runs the processor under `timeout "$safety_timeout"` and nothing inside the processor bounded conversion, so an overrun arrived as a SIGTERM and main() never regained control to reach the fallback at all. The reporter's log shows CON_ERROR one second before the shell's SAFETY TIMEOUT, which is timeout(1) signalling the process group -- whether Python lives long enough to print is a race, and long enough to import is one it loses. convert_book() and convert_to_kepub() now run the converter with subprocess.run(timeout=...), read from CWA_CONVERSION_DEADLINE_SECONDS, which the s6 script derives from the same safety_timeout it enforces so the two limits cannot drift. An overrun is an ordinary (False, "") and the book is imported in its original format. Two more holes on the same theme, both found by the cross-family review: convert_to_kepub()'s generic except printed and fell through, returning None. main() unpacks that call, so it raised TypeError, skipped the fallback and dropped the book. Every non-success path now returns a pair, and convert_book() no longer lets an OSError escape either. That same path backed up only the intermediate epub, so for a non-epub input the failed folder never held the file the user actually dropped in. It now keeps both.
Cross-family review disposition (Terra, thread
|
The deadline added in d24365a was read fresh at each subprocess, so a kepub ingest of a non-EPUB — which converts to epub and then to kepub inside a single hard timeout — could grant each stage the full deadline. Two stages could therefore outlast the watchdog they were meant to sit inside, leaving the second conversion running when SIGTERM arrived. That is the book-losing failure this issue is about, reachable at the default timeout, so the fix was incomplete for exactly one of its own paths. The budget is now a single allowance anchored to process start, which is the span the service's `timeout` wrapper is actually measuring. Each stage draws on what is left. An exhausted budget yields a small positive value rather than zero or a negative, so it expires as an ordinary TimeoutExpired the failure handler already knows how to turn into an import of the original, instead of a ValueError escaping the handlers. Two smaller repairs from the same review: The shell reserve had a cliff. A flat 30s floor applied to a small timeout inverted the two limits — a 31s timeout left 1s to convert while a 30s timeout left 23s. The reserve is now capped at a fifth of the budget, which makes the deadline monotonic in the timeout and keeps the majority of it for conversion. `ingest_timeout_minutes` is clamped to 5..120 server-side, so the shipped range never reached the cliff; the 900s and 2700s endpoints are unchanged and pinned. `float('inf')` parses cleanly and fails at `int()` with OverflowError, which is neither TypeError nor ValueError. Uncaught, it escaped while evaluating the `timeout=` argument, before subprocess.run() was called and outside the handlers that import the original. Tests: 13 added, each mutation-verified to re-red when its fix is stubbed out. Converter child processes can still outlive the deadline; that needs its own process-group lifecycle and is tracked in #1161.
The budget is anchored at process start, so the wait for the file to finish copying in is deducted from it — a file that becomes ready late leaves less time to convert. That is the correct direction (the outer watchdog starts at process launch too, so anchoring anywhere later lets the inner deadline outrun it, which is how the book gets lost), but the timeout message read as though the converter had been given all of it, which would send someone sizing their Ingest Timeout against the wrong number. Two tests document the policy rather than leave it as folklore: the anchor stays bound at import, and the message keeps saying what the budget covers. The source-pin slices on the next function boundary instead of a fixed byte window — a docstring edit had already pushed the reference past an 800-byte one, which is the failure this file's own helper warns about.
Second review round (Terra, same thread
|
| Case | Result |
|---|---|
309MB .txt, 1s budget |
deadline fires, backed up to /config/processed_books/failed, imports the original as TXT (42 → 43 books) |
short .txt, 600s budget |
converts, imports EPUB |
short .txt, no budget set |
converts, imports EPUB |
The last two are the anti-vacuity control: the fallback fires when conversion fails and not otherwise. Budget sharing confirmed on the deployed module (600s → 200s after 400s elapsed), exhausted budget returns 0.1 not a negative, and inf/1e309/banana all degrade to no-deadline. Test books removed and auto_convert restored.
Why
@auspex dropped a 63MB PDF into the ingest folder (#1094). It was picked up, spent 45 minutes in
ebook-convert, got killed by the service safety timeout — and then simply was not in the library. No entry, and the file gone from the ingest folder. Their words: "There's no reason that any output conversion must be successful, but the input file should always be ingested regardless of any conversion problem."Root cause
main()inscripts/ingest_processor.pyimported the book only insideif convert_successful:and had noelse. Any conversion failure returned without ever callingadd_book_to_library(), while thefinallyblock'sdelete_current_file()removed the source from ingest. The service then loggedSuccessfully processed.This is not timeout-specific — it hit every conversion failure: a corrupt source, unsupported internals, a format needing an input plugin the user doesn't have. That is the widest part of the blast radius, and none of it was visible to the user.
The inconsistency is the tell: the two sibling branches that decline to convert (
auto_convertoff, or the format inauto_convert_ignored_formats) both import the original already. Confirmed live — the same unconvertible PDF imports fine with auto-convert off and is lost with it on.Fix
An
elif conversion_attempted and is_rescuable_on_conversion_failure(...)branch imports the original.Correction to an earlier version of this description. It claimed the reporter's timeout case was covered by that branch. It wasn't. The service runs the processor under
timeout "$safety_timeout"(run:245) and nothing inside the processor bounded conversion, so an overrun arrived as a SIGTERM andmain()never regained control to reach the fallback. Their log showsCON_ERROR ... EXIT/ERROR CODE: -15one second before the shell'sSAFETY TIMEOUT— that istimeout(1)signalling the process group, and whether Python survives long enough to import is a race it loses. The cross-family review caught the overclaim;d24365a3cfixes the underlying gap by giving conversion its own deadline just inside the hard one, derived from the samesafety_timeoutso the two can't drift. Full disposition of all six findings is in the review comment below.The guard flag is load-bearing, not decoration. The ignore-list branch reports
convert_successful = Falsehaving already imported the original, so a bareelsewould have filed every deliberately-unconverted book twice — worse than the bug being fixed. I hit that in the blast-radius pass before pushing; it is pinned bytest_ignored_format_is_imported_exactly_once.Two smaller items from the same report:
backup()now logs its absolute destination, and the safety-timeout branch names/config/processed_books/failed/<file>. The reporter: "I've got no idea where 'failed backup' is!"ingest_timeout_minutesis read only byis_file_in_use()(ingest_processor.py:1169,:1811) — there is no internal conversion timeout at all, so that line sent anyone debugging a slow conversion looking for a limit that does not exist. It now points at the setting that does govern it.Reproduction and validation
Red/green, mutation-verified. 8 tests in
tests/unit/test_1094_failed_conversion_imports_original.pydriving the realmain()control flow over a fake processor.Against pristine
main: 4 failed — including the reported symptom,add_book_to_library calls were []. After the fix: 8 passed. Stubbing the new branch back out (elif False:) re-reds exactly the two fallback tests, so they are falsifiable rather than vacuous.The suite includes an anti-vacuity test (a successful conversion must still import the converted file) and a premise guard (
ingest_timeout_minutesmust not appear insideconvert_book), so the timeout assertions can't silently outlive their reason.Live verification (cwn-local, mirroring the reporter's config)
auto_convert=1, targetepub— the out-of-box path, not this container's prior state.CON_ERROR→Successfully processed, books 42 (lost)/book/205200,/api/v1/books/205200, PDF format attachedto failed backupto /config/processed_books/failedCross-cutting re-checks after the fix:
.txtstill converts (END_CONin 1.46s), imports one row, formatEPUB, fallback silent.pdfinauto_convert_ignored_formats: exactly one row, no conversion attempted, fallback silent.ebook-convert's own complaints about the deliberately-corrupt PDF. None from our code.Test artifacts removed and the container's settings restored (42 books,
auto_convert=0).Suite
Full unit + smoke: 3 failed / 4206 passed. All 3 fail identically on pristine
main(verified by stashing this change) —test_802_metadata_change_dispatch,test_ingest_batch_dirty,test_smoke::test_required_directories_exist. Zero regressions.Scope left out, deliberately
The reporter's item 5 — make the conversion timeout proportional to page count rather than a flat 15 minutes — is a real ask and a separate change with its own design question (what the scaling factor should be, and where the ceiling goes). Filing it as a credited child issue rather than stretching this one. Their items 1 and 3 they withdrew themselves after testing: the doubled DeDRM log is calibre's
ebook-convert, reproducible outside CWA, and the file does have a text layer.Addresses #1094.
Tier: fork-original behaviour change. No dependency, license, route or external URL change (rule 6 clear). Not auth/session/CSRF/crypto/subprocess-input/path-handling, so
/security-reviewis not triggered.