fix: never open a SQLite database on the backup volume #132

Merged
bjoern merged 2 commits from fix/backup-staging-on-local-volume into main 2026-09-10 19:12:19 +02:00
Member

What's in

Backups still fail in production after #127–#130. This is the actual cause, and it is one I got wrong the first time.

The symptom. SQLite Error 5: 'database is locked' after 5,050 ms — the whole busy_timeout — on a server that had served exactly one request since starting and had no other activity. The live database was not contended by anything.

The cause. The backup volume is a CIFS mount. VACUUM INTO '<path>' does not write a file; it creates a SQLite database at that path and takes a lock on it, and a network filesystem is precisely where that lock cannot be relied on. So did every other step: verification opens the candidate read-only, which takes a shared lock; an upload was written to the backup volume and opened there to be checked; the startup swap opened the backup where it lay.

What hid this is that everything else on that mount worked perfectly — the listing, the receipts, the images and thumbnails all live there and are plain byte I/O. Only the operations that need a lock failed, and SQLite reports a failed lock with the same error it uses for a busy database.

deploy/docker-compose.yml had already written the rule down — for the other volume:

  db:
    # Stays local — SQLite needs reliable file locking … which network
    # mounts generally cannot (DEPLOYMENT.md §2, ADR 0025).
  backups:
    external: true

…and then sent VACUUM INTO's destination to a volume that makes no such promise.

A correction to #127

#127's PR body said the root cause of the original 30,193 ms crash was busy_timeout=0 plus Microsoft.Data.Sqlite re-executing the whole statement on a 30 s command timeout. That was a real bug and it is fixed — this failure is visibly the fixed version: it waits its timeout, fails cleanly, and discards the partial instead of leaving a 0-byte file that lists as a backup. But it was not the whole cause, and I presented it as if it were. The lock was always there; #127 only made it fail fast and honestly.

The rule

Bytes cross the volume boundary; databases do not.

No code opens a SQLite database that lives in DOUJIN_MANAGER_BACKUP_DIR. Every path that creates, reads or checks one does it in <database directory>/.staging — derived from DOUJIN_MANAGER_DB_PATH, on the one volume the deployment already promises can lock a SQLite file.

  • Create — VACUUM INTO writes to staging and verifies there; only the finished file is copied across, under the .partial name, then renamed within the backup directory so publication stays atomic.
  • Upload — the request body goes to staging and is checked there. A file that is not a backup of this database is now refused without the backup volume being touched at all; it is not even created.
  • Restore request — the candidate is read through a local copy.
  • Startup swap — the candidate is copied to staging, checked there, and then moved into place rather than copied a second time. That is a move within one volume, so the live database appears whole or not at all. The previous shape copied straight across a mount that can fail mid-transfer, and a half-copied database is the one state that procedure must never produce.

Staging is swept at the top of ApplyPendingRestore, which is the one thing the host calls unconditionally at startup — before the restore marker is even looked at, because the wreckage it clears is left by a killed backup, on boots where no restore is pending.

A failure names which volume refused, at every step including the prologue. The two produce the same "database is locked" and mean opposite things — on the database volume something holds the live database; on the backup volume the destination's own lock never succeeded — and an operator cannot tell them apart from SQLite's message.

ADR 0014 amendment 4 carries the reasoning. DEPLOYMENT.md §2 now says the backups volume may be a CIFS/NFS share and the db volume may not, plus the cost: the db volume needs room for one more copy of the database while a backup or restore is in flight. Two troubleshooting rows and the compose comment follow.

Verification

  • End to end against a real server, which is what caught this class twice before:
    • backup created and verified; the backup volume left holding exactly the .db and its .verification.json — no -journal, no -wal, no -shm, no partial — and staging empty.
    • an upload of 200 random bytes refused with the server's own SQLite Error 26: 'file is not a database', with the backup directory not created and staging empty.
    • a restore requested, applied on restart (RESTORE APPLIED), the marker consumed, the previous database and both WAL sidecars in before-restore-…/, staging empty, and the backups untouched.
  • 886 backend tests (878 before). On a root environment 4 of them skip (EnforcedFileModes) and the count reads 882 passed / 4 skipped — same run, different uid.
  • Eight new tests, each mutation-probed red and restored green. The probes and what each reddens:
    • VACUUM INTO written straight to the backup volume, with verification and the copy reverted to match — the faithful old shape — reddens the SQL pin. (A partial revert that moves only the VACUUM target reddens three, because verification is then pointed at a file that no longer exists. Both directional; the first is the honest "old code" mutation.)
    • staging an upload on the backup volume, and verifying a restore candidate in place, each redden one.
    • moving the sweep back inside the marker branch reddens the no-marker boot test.
    • deleting the failingVolume reassignment before the cross-volume copy reddens the mid-snapshot test.
    • making the unreadable candidate throw instead of refusing reddens the coordinator test.
  • Two instruments worth naming, both because the obvious version of the test would have passed vacuously:
    • The VACUUM INTO target is asserted in the SQL, through a capturing DbCommandInterceptor, because the destination file has been moved on by the time the call returns.
    • The publish-side failure is driven by having that interceptor delete the backup directory while VACUUM INTO runs — the only moment a test can act from between the two volumes, since the partial's name is timestamped to the millisecond and cannot be pre-empted. Planting a file where the backup directory belongs does not reach that line: it fails in the prologue, where the message is already right for a different reason, and that first version of the test was green under the mutation it was written for.
    • Staging is broken with a file where the directory belongs, not a permission bit: CI runs as root, where a permission bit refuses nobody.
  • Not verified: the CIFS mount itself. No test runs against a filesystem that actually refuses to lock — that is not something a test can stand up here. The tests pin the property that makes the filesystem irrelevant, not the filesystem. The end-to-end run above was on local storage, like every run that missed this the first time.

Follow-ups

In docs/BACKLOG.md:

  • /health cannot tell a directory that stores bytes from one that can host a SQLite database. The volume that broke every backup passed readiness on every boot, because a CIFS mount is writable. A probe that creates a tiny database in each data directory, writes to it and drops it would have made this a first-boot message instead of a 03:30 mystery. Staging means only the db volume needs to pass it now, which makes the probe cheap and what it catches severe.
  • A backup and a restore each copy the whole database an extra time, with no progress reporting. For a metadata-only database that is tens of megabytes; worth revisiting if it grows.

🤖 Generated with Claude Code

## What's in Backups still fail in production after #127–#130. This is the actual cause, and it is one I got wrong the first time. **The symptom.** `SQLite Error 5: 'database is locked'` after **5,050 ms** — the whole `busy_timeout` — on a server that had served exactly one request since starting and had no other activity. The live database was not contended by anything. **The cause.** The backup volume is a **CIFS mount**. `VACUUM INTO '<path>'` does not write a file; it *creates a SQLite database* at that path and takes a lock on it, and a network filesystem is precisely where that lock cannot be relied on. So did every other step: verification opens the candidate read-only, which takes a shared lock; an upload was written to the backup volume and opened there to be checked; the startup swap opened the backup where it lay. What hid this is that everything *else* on that mount worked perfectly — the listing, the receipts, the images and thumbnails all live there and are plain byte I/O. Only the operations that need a **lock** failed, and SQLite reports a failed lock with the same error it uses for a busy database. **`deploy/docker-compose.yml` had already written the rule down** — for the *other* volume: ```yaml db: # Stays local — SQLite needs reliable file locking … which network # mounts generally cannot (DEPLOYMENT.md §2, ADR 0025). backups: external: true ``` …and then sent `VACUUM INTO`'s destination to a volume that makes no such promise. ### A correction to #127 #127's PR body said the root cause of the original 30,193 ms crash was `busy_timeout=0` plus Microsoft.Data.Sqlite re-executing the whole statement on a 30 s command timeout. That was a real bug and it is fixed — this failure is visibly the fixed version: it waits its timeout, fails cleanly, and discards the partial instead of leaving a 0-byte file that lists as a backup. But it was **not the whole cause**, and I presented it as if it were. The lock was always there; #127 only made it fail fast and honestly. ### The rule **Bytes cross the volume boundary; databases do not.** No code opens a SQLite database that lives in `DOUJIN_MANAGER_BACKUP_DIR`. Every path that creates, reads or checks one does it in `<database directory>/.staging` — derived from `DOUJIN_MANAGER_DB_PATH`, on the one volume the deployment already promises can lock a SQLite file. - **Create** — `VACUUM INTO` writes to staging and verifies there; only the finished file is copied across, under the `.partial` name, then renamed *within* the backup directory so publication stays atomic. - **Upload** — the request body goes to staging and is checked there. A file that is not a backup of this database is now refused **without the backup volume being touched at all**; it is not even created. - **Restore request** — the candidate is read through a local copy. - **Startup swap** — the candidate is copied to staging, checked there, and then **moved** into place rather than copied a second time. That is a move within one volume, so the live database appears whole or not at all. The previous shape copied straight across a mount that can fail mid-transfer, and a half-copied database is the one state that procedure must never produce. **Staging is swept at the top of `ApplyPendingRestore`**, which is the one thing the host calls unconditionally at startup — before the restore marker is even looked at, because the wreckage it clears is left by a killed *backup*, on boots where no restore is pending. **A failure names which volume refused**, at every step including the prologue. The two produce the same "database is locked" and mean opposite things — on the database volume something holds the live database; on the backup volume the destination's own lock never succeeded — and an operator cannot tell them apart from SQLite's message. **ADR 0014 amendment 4** carries the reasoning. `DEPLOYMENT.md` §2 now says the backups volume may be a CIFS/NFS share and the db volume may not, plus the cost: **the db volume needs room for one more copy of the database** while a backup or restore is in flight. Two troubleshooting rows and the compose comment follow. ## Verification - **End to end against a real server**, which is what caught this class twice before: - backup created and `verified`; the backup volume left holding **exactly** the `.db` and its `.verification.json` — no `-journal`, no `-wal`, no `-shm`, no partial — and staging empty. - an upload of 200 random bytes refused with the server's own `SQLite Error 26: 'file is not a database'`, with the backup directory **not created** and staging empty. - a restore requested, applied on restart (`RESTORE APPLIED`), the marker consumed, the previous database and both WAL sidecars in `before-restore-…/`, staging empty, and the backups untouched. - **886 backend tests** (878 before). On a root environment 4 of them skip (`EnforcedFileModes`) and the count reads 882 passed / 4 skipped — same run, different uid. - **Eight new tests, each mutation-probed** red and restored green. The probes and what each reddens: - `VACUUM INTO` written straight to the backup volume, *with verification and the copy reverted to match* — the faithful old shape — reddens the SQL pin. (A partial revert that moves only the `VACUUM` target reddens three, because verification is then pointed at a file that no longer exists. Both directional; the first is the honest "old code" mutation.) - staging an upload on the backup volume, and verifying a restore candidate in place, each redden one. - moving the sweep back inside the marker branch reddens the no-marker boot test. - deleting the `failingVolume` reassignment before the cross-volume copy reddens the mid-snapshot test. - making the unreadable candidate throw instead of refusing reddens the coordinator test. - **Two instruments worth naming**, both because the obvious version of the test would have passed vacuously: - The `VACUUM INTO` target is asserted **in the SQL**, through a capturing `DbCommandInterceptor`, because the destination file has been moved on by the time the call returns. - The publish-side failure is driven by having that interceptor **delete the backup directory while `VACUUM INTO` runs** — the only moment a test can act from between the two volumes, since the partial's name is timestamped to the millisecond and cannot be pre-empted. Planting a file where the backup directory belongs does *not* reach that line: it fails in the prologue, where the message is already right for a different reason, and that first version of the test was green under the mutation it was written for. - Staging is broken with a **file where the directory belongs**, not a permission bit: CI runs as root, where a permission bit refuses nobody. - **Not verified: the CIFS mount itself.** No test runs against a filesystem that actually refuses to lock — that is not something a test can stand up here. The tests pin the property that makes the filesystem irrelevant, not the filesystem. The end-to-end run above was on local storage, like every run that missed this the first time. ## Follow-ups In `docs/BACKLOG.md`: - **`/health` cannot tell a directory that stores bytes from one that can host a SQLite database.** The volume that broke every backup passed readiness on every boot, because a CIFS mount *is* writable. A probe that creates a tiny database in each data directory, writes to it and drops it would have made this a first-boot message instead of a 03:30 mystery. Staging means only the **db** volume needs to pass it now, which makes the probe cheap and what it catches severe. - A backup and a restore each copy the whole database an extra time, with no progress reporting. For a metadata-only database that is tens of megabytes; worth revisiting if it grows. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: never open a SQLite database on the backup volume
Some checks failed
CI / build (pull_request) Successful in 24s
Deploy Smoke Test / smoke (pull_request) Failing after 2m48s
CI / test (pull_request) Successful in 1m41s
99347fd5ad
Every backup on the owner's server failed with SQLite Error 5: 'database is
locked' after 5,050 ms — the whole busy_timeout — on a server that had served
one request since starting. The live database was not contended. The backup
volume is a CIFS mount.

VACUUM INTO does not write a file. It creates a SQLite database at the
destination and takes a lock on it, and a network filesystem is where that
lock cannot be relied on. So did everything else that touched a backup:
verification opens the candidate read-only, which takes a shared lock; an
upload was written to the backup volume and opened there to be checked; the
startup swap opened the backup where it lay.

What hid this is that everything else on that mount worked — the listing, the
receipts, the images and thumbnails. All plain byte I/O. Only the operations
needing a lock failed, and SQLite reports a failed lock the same way it
reports a busy database, which is why #127 read it as contention on the live
database. That diagnosis was not wrong about busy_timeout=0 and the 30 s
re-execution, but it was not the whole cause: the fix made this fail fast and
clean instead of slow and messy.

docker-compose.yml had already written the rule down for the db volume —
"SQLite needs reliable file locking … network mounts generally cannot" — and
then sent VACUUM INTO's destination to a volume that makes no such promise.

The rule is now one sentence: bytes cross the volume boundary, databases do
not. Every path that creates, reads or checks a backup database does it in
<db directory>/.staging, on the one volume the deployment already promises can
lock a SQLite file.

- Create: VACUUM INTO writes to staging and verifies there; only the finished
  file is copied across, to a .partial name, then renamed within the backup
  directory so publication stays atomic.
- Upload: the body is written to staging and checked there. A file that is not
  a backup of this database is refused without the backup volume being touched
  at all — it is not even created.
- Restore request: the candidate is read through a local copy.
- Startup swap: the candidate is copied to staging, checked there, and then
  MOVED into place rather than copied a second time. A move within one volume,
  so the live database appears whole or not at all — a half-copied database is
  the one state that procedure must never produce, and it used to copy straight
  across a mount that can fail mid-transfer.

A failure now names which volume refused. The two produce the same "database
is locked" and mean opposite things, and an operator cannot tell them apart
from SQLite's message.

ADR 0014 amendment 4, DEPLOYMENT.md §2 (the backups volume may be a share; the
db volume may not, and needs room for one more copy of the database while a
backup runs), and the compose comment.

Verified end to end against a real server: backup verified, the backup volume
left holding exactly the .db and its receipt with no SQLite sidecar, staging
empty; a rejected upload leaves the backup directory not even created; a
restore requested, applied on restart, marker consumed, previous database and
both WAL sidecars displaced, staging empty. 882 backend tests. Four new tests,
each mutation-probed red and restored green; the CIFS filesystem itself is not
simulated — the tests pin the property that makes it irrelevant.

Summary

Summary
Generated on: 09/10/2026 - 16:39:59
Coverage date: 09/10/2026 - 16:38:45 - 09/10/2026 - 16:39:56
Parser: MultiReport (4x Cobertura)
Assemblies: 4
Classes: 423
Files: 174
Line coverage: 94.6% (16188 of 17110)
Covered lines: 16188
Uncovered lines: 922
Coverable lines: 17110
Total lines: 26301
Branch coverage: 73.2% (1452 of 1983)
Covered branches: 1452
Total branches: 1983
Method coverage: Feature is only available for sponsors

Coverage

DoujinManager.ApplicationCore - 94.1%
Name Line Branch
DoujinManager.ApplicationCore 94.1% 100%
DoujinManager.ApplicationCore.Entities.Chapter 87.5%
DoujinManager.ApplicationCore.Entities.Character 100%
DoujinManager.ApplicationCore.Entities.Circle 100%
DoujinManager.ApplicationCore.Entities.Doujin 100%
DoujinManager.ApplicationCore.Entities.DoujinCharacter 75%
DoujinManager.ApplicationCore.Entities.DoujinCircle 75%
DoujinManager.ApplicationCore.Entities.DoujinPerson 80%
DoujinManager.ApplicationCore.Entities.DoujinSeries 75%
DoujinManager.ApplicationCore.Entities.DoujinTag 75%
DoujinManager.ApplicationCore.Entities.ImageFile 100%
DoujinManager.ApplicationCore.Entities.Page 80%
DoujinManager.ApplicationCore.Entities.Person 100%
DoujinManager.ApplicationCore.Entities.Series 100%
DoujinManager.ApplicationCore.Entities.Tag 100%
DoujinManager.ApplicationCore.Entities.Title 83.3%
DoujinManager.ApplicationCore.Entities.Variant 91.6%
DoujinManager.ApplicationCore.Ids.ChapterId 66.6%
DoujinManager.ApplicationCore.Ids.CharacterId 66.6%
DoujinManager.ApplicationCore.Ids.CircleId 66.6%
DoujinManager.ApplicationCore.Ids.DoujinId 100%
DoujinManager.ApplicationCore.Ids.ImageFileId 66.6%
DoujinManager.ApplicationCore.Ids.PageId 66.6%
DoujinManager.ApplicationCore.Ids.PersonId 66.6%
DoujinManager.ApplicationCore.Ids.SeriesId 66.6%
DoujinManager.ApplicationCore.Ids.TagId 66.6%
DoujinManager.ApplicationCore.Ids.TitleId 66.6%
DoujinManager.ApplicationCore.Ids.VariantId 66.6%
DoujinManager.ApplicationCore.Ports.CompressedImage 100%
DoujinManager.ApplicationCore.Ports.ExtractedImage 100%
DoujinManager.ApplicationCore.Ports.ImageInspection 100%
DoujinManager.ApplicationCore.Services.BackupContent 100%
DoujinManager.ApplicationCore.Services.BackupDirectory 100%
DoujinManager.ApplicationCore.Services.BackupFailedException 100%
DoujinManager.ApplicationCore.Services.BackupInfo 100%
DoujinManager.ApplicationCore.Services.BackupSchedule 100% 100%
DoujinManager.ApplicationCore.Services.BackupScheduleStatus 100%
DoujinManager.ApplicationCore.Services.BackupStaging 100%
DoujinManager.ApplicationCore.Services.BackupVerification 100%
DoujinManager.ApplicationCore.Services.ITagService 100%
DoujinManager.ApplicationCore.Services.RestoreRequest 100%
DoujinManager.ApplicationCore.Services.RestoreTarget 100%
DoujinManager.ApplicationCore.Services.ScheduledBackupJournal 100%
DoujinManager.ApplicationCore.Services.ScheduledBackupOutcome 100%
DoujinManager.ApplicationCore.Services.SearchKey 100% 100%
DoujinManager.ApplicationCore.Services.ServiceResult 100%
DoujinManager.ApplicationCore.Services.ServiceResult`1 100%
DoujinManager.ApplicationCore.Services.VoidResult 88.8%
DoujinManager.ApplicationCore.UseCases.AddCharacterAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddCircleAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddPersonAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddSeriesAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddTagAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.AddTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignPersonCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.AssignTagCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.CreatePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateTagCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.CreateVariantCommand 100%
DoujinManager.ApplicationCore.UseCases.DataDirectories 100%
DoujinManager.ApplicationCore.UseCases.DataDirectory 100%
DoujinManager.ApplicationCore.UseCases.DataDirectoryReadiness 100%
DoujinManager.ApplicationCore.UseCases.DeleteBackupCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.DeletePageCommand 100%
DoujinManager.ApplicationCore.UseCases.DeletePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteTagCommand 100%
DoujinManager.ApplicationCore.UseCases.DeleteVariantCommand 100%
DoujinManager.ApplicationCore.UseCases.DownloadBackupQuery 100%
DoujinManager.ApplicationCore.UseCases.DuplicateDoujinGroup 100%
DoujinManager.ApplicationCore.UseCases.DuplicateDoujinReference 100%
DoujinManager.ApplicationCore.UseCases.DuplicateImageGroup 100%
DoujinManager.ApplicationCore.UseCases.FindImageBySha256Query 100%
DoujinManager.ApplicationCore.UseCases.FindImageBySha256Result 100%
DoujinManager.ApplicationCore.UseCases.GetCharacterQuery 100%
DoujinManager.ApplicationCore.UseCases.GetCircleQuery 100%
DoujinManager.ApplicationCore.UseCases.GetCompressedImageQuery 100%
DoujinManager.ApplicationCore.UseCases.GetCompressedImageResult 100%
DoujinManager.ApplicationCore.UseCases.GetDoujinQuery 100%
DoujinManager.ApplicationCore.UseCases.GetImageQuery 100%
DoujinManager.ApplicationCore.UseCases.GetImageResult 100%
DoujinManager.ApplicationCore.UseCases.GetPersonQuery 100%
DoujinManager.ApplicationCore.UseCases.GetSeriesQuery 100%
DoujinManager.ApplicationCore.UseCases.GetTagQuery 100%
DoujinManager.ApplicationCore.UseCases.GetThumbnailQuery 100%
DoujinManager.ApplicationCore.UseCases.GetThumbnailResult 100%
DoujinManager.ApplicationCore.UseCases.GetVariantQuery 100%
DoujinManager.ApplicationCore.UseCases.ImageCoverReference 100%
DoujinManager.ApplicationCore.UseCases.ImageReference 100%
DoujinManager.ApplicationCore.UseCases.ImportBackupCommand 100%
DoujinManager.ApplicationCore.UseCases.IntegrityCheck 100%
DoujinManager.ApplicationCore.UseCases.IntegrityFinding 100%
DoujinManager.ApplicationCore.UseCases.IntegrityReport 100%
DoujinManager.ApplicationCore.UseCases.LibraryCounts 100%
DoujinManager.ApplicationCore.UseCases.LibraryStats 100%
DoujinManager.ApplicationCore.UseCases.ListChaptersQuery 100%
DoujinManager.ApplicationCore.UseCases.ListCharactersQuery 100%
DoujinManager.ApplicationCore.UseCases.ListCirclesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDuplicateDoujinsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListDuplicateImagesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListPagesQuery 100%
DoujinManager.ApplicationCore.UseCases.ListPeopleQuery 100%
DoujinManager.ApplicationCore.UseCases.ListSeriesQuery 0%
DoujinManager.ApplicationCore.UseCases.ListTagsQuery 100%
DoujinManager.ApplicationCore.UseCases.ListVariantsQuery 100%
DoujinManager.ApplicationCore.UseCases.ReadinessReport 100%
DoujinManager.ApplicationCore.UseCases.RemoveCharacterAliasCommand 0%
DoujinManager.ApplicationCore.UseCases.RemoveCharacterCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveCircleAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveCircleCommand 100%
DoujinManager.ApplicationCore.UseCases.RemovePersonAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.RemovePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveSeriesAliasCommand 0%
DoujinManager.ApplicationCore.UseCases.RemoveSeriesCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveTagAliasCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveTagCommand 100%
DoujinManager.ApplicationCore.UseCases.RemoveTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.ReorderPagesCommand 100%
DoujinManager.ApplicationCore.UseCases.RequestBackupRestoreCommand 100%
DoujinManager.ApplicationCore.UseCases.RunScheduledBackupCommand 100%
DoujinManager.ApplicationCore.UseCases.SearchDoujinsQuery 100%
DoujinManager.ApplicationCore.UseCases.SearchResult 100%
DoujinManager.ApplicationCore.UseCases.SearchResults 100%
DoujinManager.ApplicationCore.UseCases.StorageUsage 100%
DoujinManager.ApplicationCore.UseCases.UpdateChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdateCharacterCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateCircleCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateDoujinCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdatePageChapterCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdatePersonCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdateSeriesCommand 0%
DoujinManager.ApplicationCore.UseCases.UpdateTagCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdateTitleCommand 100%
DoujinManager.ApplicationCore.UseCases.UpdateVariantCommand 100%
DoujinManager.ApplicationCore.UseCases.UploadImageFile 100%
DoujinManager.ApplicationCore.UseCases.UploadPagesCommand 100%
DoujinManager.ApplicationCore.UseCases.UploadZipPagesCommand 100%
DoujinManager.Infrastructure - 96.4%
Name Line Branch
DoujinManager.Infrastructure 96.4% 82.7%
DoujinManager.Infrastructure.Archives.NaturalStringComparer 100% 90%
DoujinManager.Infrastructure.Archives.ZipExtractor 100% 87.5%
DoujinManager.Infrastructure.Data.Configurations.ChapterConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.CharacterConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.CircleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinCharacterConfigurati
on
100%
DoujinManager.Infrastructure.Data.Configurations.DoujinCircleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinPersonConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinSeriesConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.DoujinTagConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.ImageFileConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.PageConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.PersonConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.SeriesConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.TagConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.TitleConfiguration 100%
DoujinManager.Infrastructure.Data.Configurations.VariantConfiguration 100%
DoujinManager.Infrastructure.Data.DoujinManagerDbContext 100%
DoujinManager.Infrastructure.Data.GuidIdGenerator 11.1%
DoujinManager.Infrastructure.Data.Migrations.AddAliases 98.7%
DoujinManager.Infrastructure.Data.Migrations.AddCoverImageId 99.2%
DoujinManager.Infrastructure.Data.Migrations.AddDoujinCreatedAtIndex 99.4%
DoujinManager.Infrastructure.Data.Migrations.AddEntitySearchKey 96.9%
DoujinManager.Infrastructure.Data.Migrations.AddImageFileSha256Index 99.4%
DoujinManager.Infrastructure.Data.Migrations.AddScaleIndexes 98.2%
DoujinManager.Infrastructure.Data.Migrations.AddTagAliases 99.3%
DoujinManager.Infrastructure.Data.Migrations.DoujinManagerDbContextModelSna
pshot
100%
DoujinManager.Infrastructure.Data.Migrations.HarmonizeMetadataAddCharacters
Series
95.1%
DoujinManager.Infrastructure.Data.Migrations.InitialCreate 97.1%
DoujinManager.Infrastructure.Data.Migrations.RemovePreferredDisplayLanguage 99%
DoujinManager.Infrastructure.Data.ModelBuilderExtensions 50%
DoujinManager.Infrastructure.Data.SearchKeyBackfill 100% 100%
DoujinManager.Infrastructure.Data.SqliteDatabaseFile 100%
DoujinManager.Infrastructure.Data.SqliteMaintenance 78.2% 62.5%
DoujinManager.Infrastructure.Data.SqlitePragmaInterceptor 100% 75%
DoujinManager.Infrastructure.Data.SqliteRuntimeOptions 100% 100%
DoujinManager.Infrastructure.Data.StronglyTypedIdConverterFactory 100%
DoujinManager.Infrastructure.Data.WalCheckpointResult 100%
DoujinManager.Infrastructure.Images.SkiaSharpImageInspector 90.1% 83.8%
DoujinManager.Infrastructure.Images.SkiaSharpImageResizer 99% 87.5%
DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator 94.5% 66.6%
DoujinManager.Infrastructure.Services.BackupExpectations 100%
DoujinManager.Infrastructure.Services.BackupExpectationsReader 84.7% 75%
DoujinManager.Infrastructure.Services.BackupReceipts 90% 87.5%
DoujinManager.Infrastructure.Services.BackupRestoreCoordinator 70% 70.8%
DoujinManager.Infrastructure.Services.BackupRestoreService 91.9% 90.6%
DoujinManager.Infrastructure.Services.BackupService 86.7% 85.4%
DoujinManager.Infrastructure.Services.BackupStagingArea 100% 100%
DoujinManager.Infrastructure.Services.BackupVerifier 90.6% 81.8%
DoujinManager.Infrastructure.Services.ChapterService 97.2% 70%
DoujinManager.Infrastructure.Services.CharacterService 60.5% 22.7%
DoujinManager.Infrastructure.Services.CircleService 82.8% 45.4%
DoujinManager.Infrastructure.Services.DoujinOrdering 100% 100%
DoujinManager.Infrastructure.Services.DoujinService 99.4% 76.3%
DoujinManager.Infrastructure.Services.ImageCleanupHelper 100% 100%
DoujinManager.Infrastructure.Services.ImageService 93.5% 75%
DoujinManager.Infrastructure.Services.PageService 95.2% 68.1%
DoujinManager.Infrastructure.Services.ParentTouchHelper 100% 100%
DoujinManager.Infrastructure.Services.PersonService 100% 72.7%
DoujinManager.Infrastructure.Services.RestoreMarker 81.8% 83.3%
DoujinManager.Infrastructure.Services.RestoreOutcome 100%
DoujinManager.Infrastructure.Services.SearchService 100% 99%
DoujinManager.Infrastructure.Services.SeriesService 47.3% 9%
DoujinManager.Infrastructure.Services.TagService 100% 90.9%
DoujinManager.Infrastructure.Services.VariantService 96.2% 81.2%
DoujinManager.Infrastructure.Storage.DirectorySize 100% 100%
DoujinManager.Infrastructure.Storage.FilesystemImageStorage 100% 100%
DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage 95.2% 50%
DoujinManager.Infrastructure.UseCases 94.7% 86.1%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__CoverImageHelper
100% 100%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__PrimaryTitleHelper
100% 50%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__RolledBackImageCleanup
92.8% 50%
DoujinManager.Infrastructure.UseCases.FE6C43B9C917DB414605EC
E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__UploadChapterValidator
100% 100%
DoujinManager.Infrastructure.UseCases.AddCharacterAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.AddCircleAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.AddPersonAliasUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AddSeriesAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.AddTagAliasUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AddTitleUseCase 83.3% 50%
DoujinManager.Infrastructure.UseCases.AssignCharacterUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignCircleUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignPersonUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.AssignSeriesUseCase 84.6% 66.6%
DoujinManager.Infrastructure.UseCases.AssignTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CancelBackupRestoreUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CheckReadinessUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateBackupUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateChapterUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateCharacterUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateCircleUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateDoujinUseCase 94.4% 94.1%
DoujinManager.Infrastructure.UseCases.CreatePersonUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateSeriesUseCase 100%
DoujinManager.Infrastructure.UseCases.CreateTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.CreateVariantUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.DeleteBackupUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.DeleteChapterUseCase 100%
DoujinManager.Infrastructure.UseCases.DeleteCharacterUseCase 80% 50%
DoujinManager.Infrastructure.UseCases.DeleteCircleUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.DeleteDoujinUseCase 100%
DoujinManager.Infrastructure.UseCases.DeletePageUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.DeletePersonUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.DeleteSeriesUseCase 70% 50%
DoujinManager.Infrastructure.UseCases.DeleteTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.DeleteVariantUseCase 100%
DoujinManager.Infrastructure.UseCases.DownloadBackupUseCase 100%
DoujinManager.Infrastructure.UseCases.FindImageBySha256UseCase 97.1% 100%
DoujinManager.Infrastructure.UseCases.GetBackupRestoreRequestUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetBackupScheduleUseCase 100%
DoujinManager.Infrastructure.UseCases.GetCharacterUseCase 100%
DoujinManager.Infrastructure.UseCases.GetCircleUseCase 100%
DoujinManager.Infrastructure.UseCases.GetCompressedImageUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetDoujinUseCase 100%
DoujinManager.Infrastructure.UseCases.GetImageUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetIntegrityReportUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetPersonUseCase 100%
DoujinManager.Infrastructure.UseCases.GetSeriesUseCase 100%
DoujinManager.Infrastructure.UseCases.GetStatsUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.GetTagUseCase 100%
DoujinManager.Infrastructure.UseCases.GetThumbnailUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.GetVariantUseCase 100%
DoujinManager.Infrastructure.UseCases.ImportBackupUseCase 100%
DoujinManager.Infrastructure.UseCases.ListBackupsUseCase 100%
DoujinManager.Infrastructure.UseCases.ListChaptersUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.ListCharactersUseCase 100%
DoujinManager.Infrastructure.UseCases.ListCirclesUseCase 100%
DoujinManager.Infrastructure.UseCases.ListDoujinsUseCase 100%
DoujinManager.Infrastructure.UseCases.ListDuplicateDoujinsUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.ListDuplicateImagesUseCase 100% 75%
DoujinManager.Infrastructure.UseCases.ListPagesUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.ListPeopleUseCase 100%
DoujinManager.Infrastructure.UseCases.ListSeriesUseCase 0%
DoujinManager.Infrastructure.UseCases.ListTagsUseCase 100%
DoujinManager.Infrastructure.UseCases.ListVariantsUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.RemoveCharacterAliasUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.RemoveCharacterUseCase 100%
DoujinManager.Infrastructure.UseCases.RemoveCircleAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.RemoveCircleUseCase 100%
DoujinManager.Infrastructure.UseCases.RemovePersonAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.RemovePersonUseCase 100%
DoujinManager.Infrastructure.UseCases.RemoveSeriesAliasUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.RemoveSeriesUseCase 100%
DoujinManager.Infrastructure.UseCases.RemoveTagAliasUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.RemoveTagUseCase 100%
DoujinManager.Infrastructure.UseCases.RemoveTitleUseCase 100%
DoujinManager.Infrastructure.UseCases.ReorderPagesUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.RequestBackupRestoreUseCase 100%
DoujinManager.Infrastructure.UseCases.RunScheduledBackupUseCase 100% 81.8%
DoujinManager.Infrastructure.UseCases.SearchDoujinsUseCase 100%
DoujinManager.Infrastructure.UseCases.UpdateChapterUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.UpdateCharacterUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateCircleUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateDoujinUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdatePageChapterUseCase 93.7% 87.5%
DoujinManager.Infrastructure.UseCases.UpdatePersonUseCase 100% 50%
DoujinManager.Infrastructure.UseCases.UpdateSeriesUseCase 0% 0%
DoujinManager.Infrastructure.UseCases.UpdateTagUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdateTitleUseCase 100% 100%
DoujinManager.Infrastructure.UseCases.UpdateVariantUseCase 100%
DoujinManager.Infrastructure.UseCases.UploadPagesUseCase 89.7% 91.6%
DoujinManager.Infrastructure.UseCases.UploadRejectedException 100%
DoujinManager.Infrastructure.UseCases.UploadZipPagesUseCase 83.3% 90%
DoujinManager.RestAdapter - 94.3%
Name Line Branch
DoujinManager.RestAdapter 94.3% 78.7%
DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware 100% 91.6%
DoujinManager.RestAdapter.Configuration.ImageOptions 100%
DoujinManager.RestAdapter.Configuration.ServerVersion 100%
DoujinManager.RestAdapter.Configuration.UploadOptions 100%
DoujinManager.RestAdapter.Dtos.AddAliasDto 100%
DoujinManager.RestAdapter.Dtos.AssignTagDto 100%
DoujinManager.RestAdapter.Dtos.BackupDto 100%
DoujinManager.RestAdapter.Dtos.BackupScheduleDto 100% 100%
DoujinManager.RestAdapter.Dtos.BackupVerificationDto 94.7% 75%
DoujinManager.RestAdapter.Dtos.ChapterDto 100%
DoujinManager.RestAdapter.Dtos.CharacterDto 100%
DoujinManager.RestAdapter.Dtos.CircleDto 100%
DoujinManager.RestAdapter.Dtos.CreateChapterDto 100%
DoujinManager.RestAdapter.Dtos.CreateCharacterDto 100%
DoujinManager.RestAdapter.Dtos.CreateCircleDto 100%
DoujinManager.RestAdapter.Dtos.CreateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.CreatePersonDto 100%
DoujinManager.RestAdapter.Dtos.CreateSeriesDto 100%
DoujinManager.RestAdapter.Dtos.CreateTagDto 100%
DoujinManager.RestAdapter.Dtos.CreateTitleDto 100%
DoujinManager.RestAdapter.Dtos.CreateVariantDto 100%
DoujinManager.RestAdapter.Dtos.DataDirectoryHealthDto 100%
DoujinManager.RestAdapter.Dtos.DoujinDetailDto 100%
DoujinManager.RestAdapter.Dtos.DoujinPersonDto 100%
DoujinManager.RestAdapter.Dtos.DoujinSummaryDto 100%
DoujinManager.RestAdapter.Dtos.DuplicateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.DuplicateDoujinGroupDto 100%
DoujinManager.RestAdapter.Dtos.DuplicateImageGroupDto 100%
DoujinManager.RestAdapter.Dtos.HealthDto 100%
DoujinManager.RestAdapter.Dtos.ImageBySha256Dto 100%
DoujinManager.RestAdapter.Dtos.ImageCoverReferenceDto 100%
DoujinManager.RestAdapter.Dtos.ImageFileSummaryDto 0%
DoujinManager.RestAdapter.Dtos.ImageReferenceDto 100%
DoujinManager.RestAdapter.Dtos.IntegrityCheckDto 100%
DoujinManager.RestAdapter.Dtos.IntegrityFindingDto 100%
DoujinManager.RestAdapter.Dtos.IntegrityReportDto 100%
DoujinManager.RestAdapter.Dtos.LinkCharacterDto 100%
DoujinManager.RestAdapter.Dtos.LinkCircleDto 100%
DoujinManager.RestAdapter.Dtos.LinkPersonDto 100%
DoujinManager.RestAdapter.Dtos.LinkSeriesDto 100%
DoujinManager.RestAdapter.Dtos.PageDetailDto 100%
DoujinManager.RestAdapter.Dtos.PersonDto 100%
DoujinManager.RestAdapter.Dtos.ReorderPagesDto 100%
DoujinManager.RestAdapter.Dtos.RestoreRequestDto 100%
DoujinManager.RestAdapter.Dtos.ScheduledBackupRunDto 100% 100%
DoujinManager.RestAdapter.Dtos.SearchDoujinsDto 100%
DoujinManager.RestAdapter.Dtos.SearchResultDto 100%
DoujinManager.RestAdapter.Dtos.SeriesDto 100%
DoujinManager.RestAdapter.Dtos.StatsCountsDto 100%
DoujinManager.RestAdapter.Dtos.StatsDto 100%
DoujinManager.RestAdapter.Dtos.StatsStorageDto 100%
DoujinManager.RestAdapter.Dtos.TagDto 100%
DoujinManager.RestAdapter.Dtos.TitleDto 100%
DoujinManager.RestAdapter.Dtos.UpdateChapterDto 100%
DoujinManager.RestAdapter.Dtos.UpdateCharacterDto 0%
DoujinManager.RestAdapter.Dtos.UpdateCircleDto 0%
DoujinManager.RestAdapter.Dtos.UpdateDoujinDto 100%
DoujinManager.RestAdapter.Dtos.UpdatePageChapterDto 100%
DoujinManager.RestAdapter.Dtos.UpdatePersonDto 100%
DoujinManager.RestAdapter.Dtos.UpdateSeriesDto 0%
DoujinManager.RestAdapter.Dtos.UpdateTagDto 100%
DoujinManager.RestAdapter.Dtos.UpdateTitleDto 100%
DoujinManager.RestAdapter.Dtos.UpdateVariantDto 100%
DoujinManager.RestAdapter.Dtos.UploadPagesResponseDto 100%
DoujinManager.RestAdapter.Dtos.VariantDetailDto 100%
DoujinManager.RestAdapter.Dtos.VariantSummaryDto 100%
DoujinManager.RestAdapter.Endpoints.BackupEndpoints 99%
DoujinManager.RestAdapter.Endpoints.DoujinEndpoints 97.4% 55.5%
DoujinManager.RestAdapter.Endpoints.HealthEndpoints 100%
DoujinManager.RestAdapter.Endpoints.ImageEndpoints 97.6% 75%
DoujinManager.RestAdapter.Endpoints.IntegrityEndpoints 100%
DoujinManager.RestAdapter.Endpoints.MetadataEndpoints 88.3% 43.7%
DoujinManager.RestAdapter.Endpoints.PageMapper 100% 58.3%
DoujinManager.RestAdapter.Endpoints.PaginationParams 100%
DoujinManager.RestAdapter.Endpoints.SearchEndpoints 100% 75%
DoujinManager.RestAdapter.Endpoints.StatsEndpoints 100%
DoujinManager.RestAdapter.Endpoints.VariantEndpoints 100% 100%
DoujinManager.RestAdapter.Envelopes.CollectionResponse`1 83.3%
DoujinManager.RestAdapter.Envelopes.EnvelopeDefaults 100%
DoujinManager.RestAdapter.Envelopes.EnvelopeJsonOptions 100%
DoujinManager.RestAdapter.Envelopes.ErrorResponse 100%
DoujinManager.RestAdapter.Envelopes.HypermediaAction 100%
DoujinManager.RestAdapter.Envelopes.HypermediaHelpers 98.1% 100%
DoujinManager.RestAdapter.Envelopes.Link 100%
DoujinManager.RestAdapter.Envelopes.MediaTypeJsonConverter 88.8% 87.5%
DoujinManager.RestAdapter.Envelopes.PageInfo 100%
DoujinManager.RestAdapter.Envelopes.ResourceResponse`1 80%
DoujinManager.RestAdapter.Envelopes.ValidationError 100%
DoujinManager.RestAdapter.Envelopes.ValidationErrorResponse 100%
DoujinManager.RestAdapter.Middleware.GlobalExceptionMiddleware 100% 62.5%
DoujinManager.RestAdapter.Middleware.RequestLoggingMiddleware 100% 91.3%
DoujinManager.RestAdapter.RestAdapterExtensions 100% 100%
Microsoft.Extensions.Validation.Generated 91.1% 89.3%
Microsoft.Extensions.Validation.Generated.<ValidatableInfoResolver_g>FB9B0C
E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr
ibuteCache
100% 62.5%
System.Runtime.CompilerServices 0%
DoujinManager.Server - 62.4%
Name Line Branch
DoujinManager.Server 62.4% 23.8%
DoujinManager.Server.BackupRegistration 100% 100%
DoujinManager.Server.BackupScheduleOptions 100%
DoujinManager.Server.BearerSecuritySchemeTransformer 100% 78.5%
DoujinManager.Server.DatabaseRegistration 89.6%
DoujinManager.Server.ImageInfrastructureRegistration 100%
DoujinManager.Server.ProxyAwareServerTransformer 100% 75%
DoujinManager.Server.ReadinessRegistration 100%
DoujinManager.Server.ScalarUi 100%
DoujinManager.Server.ScheduledBackupConfiguration 100% 87.5%
DoujinManager.Server.ScheduledBackupService 73.6% 50%
DoujinManager.Server.SqliteRuntimeConfiguration 96.5% 95%
DoujinManager.Server.UseCaseRegistrationHelper 100%
Microsoft.AspNetCore.OpenApi.Generated 1.3% 0%
Program 87.7% 36.6%
System.Runtime.CompilerServices 0%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 09/10/2026 - 16:39:59 | | Coverage date: | 09/10/2026 - 16:38:45 - 09/10/2026 - 16:39:56 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 4 | | Classes: | 423 | | Files: | 174 | | **Line coverage:** | 94.6% (16188 of 17110) | | Covered lines: | 16188 | | Uncovered lines: | 922 | | Coverable lines: | 17110 | | Total lines: | 26301 | | **Branch coverage:** | 73.2% (1452 of 1983) | | Covered branches: | 1452 | | Total branches: | 1983 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>DoujinManager.ApplicationCore - 94.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.ApplicationCore**|**94.1%**|**100%**| |DoujinManager.ApplicationCore.Entities.Chapter|87.5%|| |DoujinManager.ApplicationCore.Entities.Character|100%|| |DoujinManager.ApplicationCore.Entities.Circle|100%|| |DoujinManager.ApplicationCore.Entities.Doujin|100%|| |DoujinManager.ApplicationCore.Entities.DoujinCharacter|75%|| |DoujinManager.ApplicationCore.Entities.DoujinCircle|75%|| |DoujinManager.ApplicationCore.Entities.DoujinPerson|80%|| |DoujinManager.ApplicationCore.Entities.DoujinSeries|75%|| |DoujinManager.ApplicationCore.Entities.DoujinTag|75%|| |DoujinManager.ApplicationCore.Entities.ImageFile|100%|| |DoujinManager.ApplicationCore.Entities.Page|80%|| |DoujinManager.ApplicationCore.Entities.Person|100%|| |DoujinManager.ApplicationCore.Entities.Series|100%|| |DoujinManager.ApplicationCore.Entities.Tag|100%|| |DoujinManager.ApplicationCore.Entities.Title|83.3%|| |DoujinManager.ApplicationCore.Entities.Variant|91.6%|| |DoujinManager.ApplicationCore.Ids.ChapterId|66.6%|| |DoujinManager.ApplicationCore.Ids.CharacterId|66.6%|| |DoujinManager.ApplicationCore.Ids.CircleId|66.6%|| |DoujinManager.ApplicationCore.Ids.DoujinId|100%|| |DoujinManager.ApplicationCore.Ids.ImageFileId|66.6%|| |DoujinManager.ApplicationCore.Ids.PageId|66.6%|| |DoujinManager.ApplicationCore.Ids.PersonId|66.6%|| |DoujinManager.ApplicationCore.Ids.SeriesId|66.6%|| |DoujinManager.ApplicationCore.Ids.TagId|66.6%|| |DoujinManager.ApplicationCore.Ids.TitleId|66.6%|| |DoujinManager.ApplicationCore.Ids.VariantId|66.6%|| |DoujinManager.ApplicationCore.Ports.CompressedImage|100%|| |DoujinManager.ApplicationCore.Ports.ExtractedImage|100%|| |DoujinManager.ApplicationCore.Ports.ImageInspection|100%|| |DoujinManager.ApplicationCore.Services.BackupContent|100%|| |DoujinManager.ApplicationCore.Services.BackupDirectory|100%|| |DoujinManager.ApplicationCore.Services.BackupFailedException|100%|| |DoujinManager.ApplicationCore.Services.BackupInfo|100%|| |DoujinManager.ApplicationCore.Services.BackupSchedule|100%|100%| |DoujinManager.ApplicationCore.Services.BackupScheduleStatus|100%|| |DoujinManager.ApplicationCore.Services.BackupStaging|100%|| |DoujinManager.ApplicationCore.Services.BackupVerification|100%|| |DoujinManager.ApplicationCore.Services.ITagService|100%|| |DoujinManager.ApplicationCore.Services.RestoreRequest|100%|| |DoujinManager.ApplicationCore.Services.RestoreTarget|100%|| |DoujinManager.ApplicationCore.Services.ScheduledBackupJournal|100%|| |DoujinManager.ApplicationCore.Services.ScheduledBackupOutcome|100%|| |DoujinManager.ApplicationCore.Services.SearchKey|100%|100%| |DoujinManager.ApplicationCore.Services.ServiceResult|100%|| |DoujinManager.ApplicationCore.Services.ServiceResult`1|100%|| |DoujinManager.ApplicationCore.Services.VoidResult|88.8%|| |DoujinManager.ApplicationCore.UseCases.AddCharacterAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddCircleAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddPersonAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddSeriesAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddTagAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AddTitleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignPersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.AssignTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreatePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateTitleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.CreateVariantCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DataDirectories|100%|| |DoujinManager.ApplicationCore.UseCases.DataDirectory|100%|| |DoujinManager.ApplicationCore.UseCases.DataDirectoryReadiness|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteBackupCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeletePageCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeletePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DeleteVariantCommand|100%|| |DoujinManager.ApplicationCore.UseCases.DownloadBackupQuery|100%|| |DoujinManager.ApplicationCore.UseCases.DuplicateDoujinGroup|100%|| |DoujinManager.ApplicationCore.UseCases.DuplicateDoujinReference|100%|| |DoujinManager.ApplicationCore.UseCases.DuplicateImageGroup|100%|| |DoujinManager.ApplicationCore.UseCases.FindImageBySha256Query|100%|| |DoujinManager.ApplicationCore.UseCases.FindImageBySha256Result|100%|| |DoujinManager.ApplicationCore.UseCases.GetCharacterQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetCircleQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetCompressedImageQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetCompressedImageResult|100%|| |DoujinManager.ApplicationCore.UseCases.GetDoujinQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetImageQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetImageResult|100%|| |DoujinManager.ApplicationCore.UseCases.GetPersonQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetSeriesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetTagQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetThumbnailQuery|100%|| |DoujinManager.ApplicationCore.UseCases.GetThumbnailResult|100%|| |DoujinManager.ApplicationCore.UseCases.GetVariantQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ImageCoverReference|100%|| |DoujinManager.ApplicationCore.UseCases.ImageReference|100%|| |DoujinManager.ApplicationCore.UseCases.ImportBackupCommand|100%|| |DoujinManager.ApplicationCore.UseCases.IntegrityCheck|100%|| |DoujinManager.ApplicationCore.UseCases.IntegrityFinding|100%|| |DoujinManager.ApplicationCore.UseCases.IntegrityReport|100%|| |DoujinManager.ApplicationCore.UseCases.LibraryCounts|100%|| |DoujinManager.ApplicationCore.UseCases.LibraryStats|100%|| |DoujinManager.ApplicationCore.UseCases.ListChaptersQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListCharactersQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListCirclesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDoujinsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDuplicateDoujinsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListDuplicateImagesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListPagesQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListPeopleQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListSeriesQuery|0%|| |DoujinManager.ApplicationCore.UseCases.ListTagsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ListVariantsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.ReadinessReport|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCharacterAliasCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemoveCharacterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCircleAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveCircleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemovePersonAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemovePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveSeriesAliasCommand|0%|| |DoujinManager.ApplicationCore.UseCases.RemoveSeriesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveTagAliasCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RemoveTitleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.ReorderPagesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RequestBackupRestoreCommand|100%|| |DoujinManager.ApplicationCore.UseCases.RunScheduledBackupCommand|100%|| |DoujinManager.ApplicationCore.UseCases.SearchDoujinsQuery|100%|| |DoujinManager.ApplicationCore.UseCases.SearchResult|100%|| |DoujinManager.ApplicationCore.UseCases.SearchResults|100%|| |DoujinManager.ApplicationCore.UseCases.StorageUsage|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateCharacterCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateCircleCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateDoujinCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdatePageChapterCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdatePersonCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateSeriesCommand|0%|| |DoujinManager.ApplicationCore.UseCases.UpdateTagCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateTitleCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UpdateVariantCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UploadImageFile|100%|| |DoujinManager.ApplicationCore.UseCases.UploadPagesCommand|100%|| |DoujinManager.ApplicationCore.UseCases.UploadZipPagesCommand|100%|| </details> <details><summary>DoujinManager.Infrastructure - 96.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Infrastructure**|**96.4%**|**82.7%**| |DoujinManager.Infrastructure.Archives.NaturalStringComparer|100%|90%| |DoujinManager.Infrastructure.Archives.ZipExtractor|100%|87.5%| |DoujinManager.Infrastructure.Data.Configurations.ChapterConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.CharacterConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.CircleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinCharacterConfigurati<br/>on|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinCircleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinPersonConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinSeriesConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.DoujinTagConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.ImageFileConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.PageConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.PersonConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.SeriesConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.TagConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.TitleConfiguration|100%|| |DoujinManager.Infrastructure.Data.Configurations.VariantConfiguration|100%|| |DoujinManager.Infrastructure.Data.DoujinManagerDbContext|100%|| |DoujinManager.Infrastructure.Data.GuidIdGenerator|11.1%|| |DoujinManager.Infrastructure.Data.Migrations.AddAliases|98.7%|| |DoujinManager.Infrastructure.Data.Migrations.AddCoverImageId|99.2%|| |DoujinManager.Infrastructure.Data.Migrations.AddDoujinCreatedAtIndex|99.4%|| |DoujinManager.Infrastructure.Data.Migrations.AddEntitySearchKey|96.9%|| |DoujinManager.Infrastructure.Data.Migrations.AddImageFileSha256Index|99.4%|| |DoujinManager.Infrastructure.Data.Migrations.AddScaleIndexes|98.2%|| |DoujinManager.Infrastructure.Data.Migrations.AddTagAliases|99.3%|| |DoujinManager.Infrastructure.Data.Migrations.DoujinManagerDbContextModelSna<br/>pshot|100%|| |DoujinManager.Infrastructure.Data.Migrations.HarmonizeMetadataAddCharacters<br/>Series|95.1%|| |DoujinManager.Infrastructure.Data.Migrations.InitialCreate|97.1%|| |DoujinManager.Infrastructure.Data.Migrations.RemovePreferredDisplayLanguage|99%|| |DoujinManager.Infrastructure.Data.ModelBuilderExtensions|50%|| |DoujinManager.Infrastructure.Data.SearchKeyBackfill|100%|100%| |DoujinManager.Infrastructure.Data.SqliteDatabaseFile|100%|| |DoujinManager.Infrastructure.Data.SqliteMaintenance|78.2%|62.5%| |DoujinManager.Infrastructure.Data.SqlitePragmaInterceptor|100%|75%| |DoujinManager.Infrastructure.Data.SqliteRuntimeOptions|100%|100%| |DoujinManager.Infrastructure.Data.StronglyTypedIdConverterFactory|100%|| |DoujinManager.Infrastructure.Data.WalCheckpointResult|100%|| |DoujinManager.Infrastructure.Images.SkiaSharpImageInspector|90.1%|83.8%| |DoujinManager.Infrastructure.Images.SkiaSharpImageResizer|99%|87.5%| |DoujinManager.Infrastructure.Images.SkiaSharpThumbnailGenerator|94.5%|66.6%| |DoujinManager.Infrastructure.Services.BackupExpectations|100%|| |DoujinManager.Infrastructure.Services.BackupExpectationsReader|84.7%|75%| |DoujinManager.Infrastructure.Services.BackupReceipts|90%|87.5%| |DoujinManager.Infrastructure.Services.BackupRestoreCoordinator|70%|70.8%| |DoujinManager.Infrastructure.Services.BackupRestoreService|91.9%|90.6%| |DoujinManager.Infrastructure.Services.BackupService|86.7%|85.4%| |DoujinManager.Infrastructure.Services.BackupStagingArea|100%|100%| |DoujinManager.Infrastructure.Services.BackupVerifier|90.6%|81.8%| |DoujinManager.Infrastructure.Services.ChapterService|97.2%|70%| |DoujinManager.Infrastructure.Services.CharacterService|60.5%|22.7%| |DoujinManager.Infrastructure.Services.CircleService|82.8%|45.4%| |DoujinManager.Infrastructure.Services.DoujinOrdering|100%|100%| |DoujinManager.Infrastructure.Services.DoujinService|99.4%|76.3%| |DoujinManager.Infrastructure.Services.ImageCleanupHelper|100%|100%| |DoujinManager.Infrastructure.Services.ImageService|93.5%|75%| |DoujinManager.Infrastructure.Services.PageService|95.2%|68.1%| |DoujinManager.Infrastructure.Services.ParentTouchHelper|100%|100%| |DoujinManager.Infrastructure.Services.PersonService|100%|72.7%| |DoujinManager.Infrastructure.Services.RestoreMarker|81.8%|83.3%| |DoujinManager.Infrastructure.Services.RestoreOutcome|100%|| |DoujinManager.Infrastructure.Services.SearchService|100%|99%| |DoujinManager.Infrastructure.Services.SeriesService|47.3%|9%| |DoujinManager.Infrastructure.Services.TagService|100%|90.9%| |DoujinManager.Infrastructure.Services.VariantService|96.2%|81.2%| |DoujinManager.Infrastructure.Storage.DirectorySize|100%|100%| |DoujinManager.Infrastructure.Storage.FilesystemImageStorage|100%|100%| |DoujinManager.Infrastructure.Storage.FilesystemThumbnailStorage|95.2%|50%| |DoujinManager.Infrastructure.UseCases|94.7%|86.1%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__CoverImageHelper|100%|100%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__PrimaryTitleHelper|100%|50%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__RolledBackImageCleanup|92.8%|50%| |DoujinManager.Infrastructure.UseCases.<ImageUseCases>FE6C43B9C917DB414605EC<br/>E20E2ABA2099901FFAB4904C0513DF077BA6D6CA374__UploadChapterValidator|100%|100%| |DoujinManager.Infrastructure.UseCases.AddCharacterAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.AddCircleAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.AddPersonAliasUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AddSeriesAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.AddTagAliasUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AddTitleUseCase|83.3%|50%| |DoujinManager.Infrastructure.UseCases.AssignCharacterUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignCircleUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignPersonUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.AssignSeriesUseCase|84.6%|66.6%| |DoujinManager.Infrastructure.UseCases.AssignTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CancelBackupRestoreUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CheckReadinessUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateBackupUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateChapterUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateCharacterUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateCircleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateDoujinUseCase|94.4%|94.1%| |DoujinManager.Infrastructure.UseCases.CreatePersonUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateSeriesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.CreateTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.CreateVariantUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.DeleteBackupUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.DeleteChapterUseCase|100%|| |DoujinManager.Infrastructure.UseCases.DeleteCharacterUseCase|80%|50%| |DoujinManager.Infrastructure.UseCases.DeleteCircleUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.DeleteDoujinUseCase|100%|| |DoujinManager.Infrastructure.UseCases.DeletePageUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.DeletePersonUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.DeleteSeriesUseCase|70%|50%| |DoujinManager.Infrastructure.UseCases.DeleteTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.DeleteVariantUseCase|100%|| |DoujinManager.Infrastructure.UseCases.DownloadBackupUseCase|100%|| |DoujinManager.Infrastructure.UseCases.FindImageBySha256UseCase|97.1%|100%| |DoujinManager.Infrastructure.UseCases.GetBackupRestoreRequestUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetBackupScheduleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.GetCharacterUseCase|100%|| |DoujinManager.Infrastructure.UseCases.GetCircleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.GetCompressedImageUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetDoujinUseCase|100%|| |DoujinManager.Infrastructure.UseCases.GetImageUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetIntegrityReportUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetPersonUseCase|100%|| |DoujinManager.Infrastructure.UseCases.GetSeriesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.GetStatsUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.GetTagUseCase|100%|| |DoujinManager.Infrastructure.UseCases.GetThumbnailUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.GetVariantUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ImportBackupUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListBackupsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListChaptersUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.ListCharactersUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListCirclesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListDoujinsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListDuplicateDoujinsUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.ListDuplicateImagesUseCase|100%|75%| |DoujinManager.Infrastructure.UseCases.ListPagesUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.ListPeopleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListSeriesUseCase|0%|| |DoujinManager.Infrastructure.UseCases.ListTagsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ListVariantsUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.RemoveCharacterAliasUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.RemoveCharacterUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemoveCircleAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.RemoveCircleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemovePersonAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.RemovePersonUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemoveSeriesAliasUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.RemoveSeriesUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemoveTagAliasUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.RemoveTagUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RemoveTitleUseCase|100%|| |DoujinManager.Infrastructure.UseCases.ReorderPagesUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.RequestBackupRestoreUseCase|100%|| |DoujinManager.Infrastructure.UseCases.RunScheduledBackupUseCase|100%|81.8%| |DoujinManager.Infrastructure.UseCases.SearchDoujinsUseCase|100%|| |DoujinManager.Infrastructure.UseCases.UpdateChapterUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.UpdateCharacterUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateCircleUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateDoujinUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdatePageChapterUseCase|93.7%|87.5%| |DoujinManager.Infrastructure.UseCases.UpdatePersonUseCase|100%|50%| |DoujinManager.Infrastructure.UseCases.UpdateSeriesUseCase|0%|0%| |DoujinManager.Infrastructure.UseCases.UpdateTagUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdateTitleUseCase|100%|100%| |DoujinManager.Infrastructure.UseCases.UpdateVariantUseCase|100%|| |DoujinManager.Infrastructure.UseCases.UploadPagesUseCase|89.7%|91.6%| |DoujinManager.Infrastructure.UseCases.UploadRejectedException|100%|| |DoujinManager.Infrastructure.UseCases.UploadZipPagesUseCase|83.3%|90%| </details> <details><summary>DoujinManager.RestAdapter - 94.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.RestAdapter**|**94.3%**|**78.7%**| |DoujinManager.RestAdapter.Auth.StaticBearerTokenAuthMiddleware|100%|91.6%| |DoujinManager.RestAdapter.Configuration.ImageOptions|100%|| |DoujinManager.RestAdapter.Configuration.ServerVersion|100%|| |DoujinManager.RestAdapter.Configuration.UploadOptions|100%|| |DoujinManager.RestAdapter.Dtos.AddAliasDto|100%|| |DoujinManager.RestAdapter.Dtos.AssignTagDto|100%|| |DoujinManager.RestAdapter.Dtos.BackupDto|100%|| |DoujinManager.RestAdapter.Dtos.BackupScheduleDto|100%|100%| |DoujinManager.RestAdapter.Dtos.BackupVerificationDto|94.7%|75%| |DoujinManager.RestAdapter.Dtos.ChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.CharacterDto|100%|| |DoujinManager.RestAdapter.Dtos.CircleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateCharacterDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateCircleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.CreatePersonDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateSeriesDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateTagDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateTitleDto|100%|| |DoujinManager.RestAdapter.Dtos.CreateVariantDto|100%|| |DoujinManager.RestAdapter.Dtos.DataDirectoryHealthDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinPersonDto|100%|| |DoujinManager.RestAdapter.Dtos.DoujinSummaryDto|100%|| |DoujinManager.RestAdapter.Dtos.DuplicateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.DuplicateDoujinGroupDto|100%|| |DoujinManager.RestAdapter.Dtos.DuplicateImageGroupDto|100%|| |DoujinManager.RestAdapter.Dtos.HealthDto|100%|| |DoujinManager.RestAdapter.Dtos.ImageBySha256Dto|100%|| |DoujinManager.RestAdapter.Dtos.ImageCoverReferenceDto|100%|| |DoujinManager.RestAdapter.Dtos.ImageFileSummaryDto|0%|| |DoujinManager.RestAdapter.Dtos.ImageReferenceDto|100%|| |DoujinManager.RestAdapter.Dtos.IntegrityCheckDto|100%|| |DoujinManager.RestAdapter.Dtos.IntegrityFindingDto|100%|| |DoujinManager.RestAdapter.Dtos.IntegrityReportDto|100%|| |DoujinManager.RestAdapter.Dtos.LinkCharacterDto|100%|| |DoujinManager.RestAdapter.Dtos.LinkCircleDto|100%|| |DoujinManager.RestAdapter.Dtos.LinkPersonDto|100%|| |DoujinManager.RestAdapter.Dtos.LinkSeriesDto|100%|| |DoujinManager.RestAdapter.Dtos.PageDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.PersonDto|100%|| |DoujinManager.RestAdapter.Dtos.ReorderPagesDto|100%|| |DoujinManager.RestAdapter.Dtos.RestoreRequestDto|100%|| |DoujinManager.RestAdapter.Dtos.ScheduledBackupRunDto|100%|100%| |DoujinManager.RestAdapter.Dtos.SearchDoujinsDto|100%|| |DoujinManager.RestAdapter.Dtos.SearchResultDto|100%|| |DoujinManager.RestAdapter.Dtos.SeriesDto|100%|| |DoujinManager.RestAdapter.Dtos.StatsCountsDto|100%|| |DoujinManager.RestAdapter.Dtos.StatsDto|100%|| |DoujinManager.RestAdapter.Dtos.StatsStorageDto|100%|| |DoujinManager.RestAdapter.Dtos.TagDto|100%|| |DoujinManager.RestAdapter.Dtos.TitleDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateCharacterDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateCircleDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateDoujinDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdatePageChapterDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdatePersonDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateSeriesDto|0%|| |DoujinManager.RestAdapter.Dtos.UpdateTagDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateTitleDto|100%|| |DoujinManager.RestAdapter.Dtos.UpdateVariantDto|100%|| |DoujinManager.RestAdapter.Dtos.UploadPagesResponseDto|100%|| |DoujinManager.RestAdapter.Dtos.VariantDetailDto|100%|| |DoujinManager.RestAdapter.Dtos.VariantSummaryDto|100%|| |DoujinManager.RestAdapter.Endpoints.BackupEndpoints|99%|| |DoujinManager.RestAdapter.Endpoints.DoujinEndpoints|97.4%|55.5%| |DoujinManager.RestAdapter.Endpoints.HealthEndpoints|100%|| |DoujinManager.RestAdapter.Endpoints.ImageEndpoints|97.6%|75%| |DoujinManager.RestAdapter.Endpoints.IntegrityEndpoints|100%|| |DoujinManager.RestAdapter.Endpoints.MetadataEndpoints|88.3%|43.7%| |DoujinManager.RestAdapter.Endpoints.PageMapper|100%|58.3%| |DoujinManager.RestAdapter.Endpoints.PaginationParams|100%|| |DoujinManager.RestAdapter.Endpoints.SearchEndpoints|100%|75%| |DoujinManager.RestAdapter.Endpoints.StatsEndpoints|100%|| |DoujinManager.RestAdapter.Endpoints.VariantEndpoints|100%|100%| |DoujinManager.RestAdapter.Envelopes.CollectionResponse`1|83.3%|| |DoujinManager.RestAdapter.Envelopes.EnvelopeDefaults|100%|| |DoujinManager.RestAdapter.Envelopes.EnvelopeJsonOptions|100%|| |DoujinManager.RestAdapter.Envelopes.ErrorResponse|100%|| |DoujinManager.RestAdapter.Envelopes.HypermediaAction|100%|| |DoujinManager.RestAdapter.Envelopes.HypermediaHelpers|98.1%|100%| |DoujinManager.RestAdapter.Envelopes.Link|100%|| |DoujinManager.RestAdapter.Envelopes.MediaTypeJsonConverter|88.8%|87.5%| |DoujinManager.RestAdapter.Envelopes.PageInfo|100%|| |DoujinManager.RestAdapter.Envelopes.ResourceResponse`1|80%|| |DoujinManager.RestAdapter.Envelopes.ValidationError|100%|| |DoujinManager.RestAdapter.Envelopes.ValidationErrorResponse|100%|| |DoujinManager.RestAdapter.Middleware.GlobalExceptionMiddleware|100%|62.5%| |DoujinManager.RestAdapter.Middleware.RequestLoggingMiddleware|100%|91.3%| |DoujinManager.RestAdapter.RestAdapterExtensions|100%|100%| |Microsoft.Extensions.Validation.Generated|91.1%|89.3%| |Microsoft.Extensions.Validation.Generated.<ValidatableInfoResolver_g>FB9B0C<br/>E5CB12CEAC5BBBEA65844E1C05F9ADA4DA38B848075C41304FC6A1ABF77__ValidationAttr<br/>ibuteCache|100%|62.5%| |System.Runtime.CompilerServices|0%|| </details> <details><summary>DoujinManager.Server - 62.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**DoujinManager.Server**|**62.4%**|**23.8%**| |DoujinManager.Server.BackupRegistration|100%|100%| |DoujinManager.Server.BackupScheduleOptions|100%|| |DoujinManager.Server.BearerSecuritySchemeTransformer|100%|78.5%| |DoujinManager.Server.DatabaseRegistration|89.6%|| |DoujinManager.Server.ImageInfrastructureRegistration|100%|| |DoujinManager.Server.ProxyAwareServerTransformer|100%|75%| |DoujinManager.Server.ReadinessRegistration|100%|| |DoujinManager.Server.ScalarUi|100%|| |DoujinManager.Server.ScheduledBackupConfiguration|100%|87.5%| |DoujinManager.Server.ScheduledBackupService|73.6%|50%| |DoujinManager.Server.SqliteRuntimeConfiguration|96.5%|95%| |DoujinManager.Server.UseCaseRegistrationHelper|100%|| |Microsoft.AspNetCore.OpenApi.Generated|1.3%|0%| |Program|87.7%|36.6%| |System.Runtime.CompilerServices|0%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! A production root-cause hunt that ends in a one-sentence architectural rule — "bytes cross the volume boundary; databases do not" — applied to all four paths at once, with an ADR amendment that admits the previous diagnosis was incomplete. This is my favorite kind of PR~ ♪ I read every line of the diff, every changed file in full, ran your whole suite, and re-ran your mutation probes myself with my own hands. The rule is beautiful. The discipline is beautiful. And two things are not true yet, and you know how I feel about things that are not true~ ♡

Verdict: ⛔ I can't let this pass~ ♡

⛔ These need fixing before I'm satisfied~

  1. backend/src/DoujinManager.Infrastructure/Services/BackupStagingArea.cs:86 + BackupRestoreCoordinator.cs:137 — the startup sweep does not exist on a normal boot. Sweep's own doc says "Called once at startup", Program.cs:161 calls only ApplyPendingRestore, and ApplyPendingRestore returns None at line 89 when no restore marker exists — Sweep at Apply:137 sits inside the marker-present branch. grep confirms no other call site, and there is no age-based cleanup for staging (the StalePartialAge sweep covers only the backup volume's .partial files). So the exact scenario your docs describe — "anything present is wreckage from a killed backup … and it is occupying the database volume" — is the one scenario the sweep misses: a backup killed mid-VACUUM (OOM, container restart), then a reboot with no marker → the staged copy of the whole database survives on the db volume indefinitely. The next CreateBackupAsync Reserves a fresh timestamped name and never touches the old one. Meanwhile three documents promise the opposite: DEPLOYMENT.md §3 ("It is swept at every start"), ADR 0014 amendment 4 ("Staging is swept at startup"), and the code's own XML doc. An operator who reads §3 will trust a cleanup that never runs — on the one volume you just told them needs headroom. Fufu~ a documented invariant with no code and no test behind it… you wouldn't leave THAT in production, would you? ♡
    Fix: move the sweep to run unconditionally at startup — Program.cs, immediately before ApplyPendingRestore (pass app.Logger; it already has dbPath). Keeping the sweep inside Apply too is fine (harmless double-sweep on a restore boot). Pin it: seed staging with a leftover file, run the startup-shaped call with no marker present, assert the leftover is gone — that no-marker arm is precisely the branch that is dark today.

  2. BackupService.cs:165–171/203/229–232 and BackupRestoreCoordinator.cs:141–148 — the new failure-reporting arms are pinned by nothing. The volume-naming is a headline feature of this PR: the body, ADR amendment 4, and the new DEPLOYMENT.md troubleshooting row ("The error message names which volume failed") all sell it. Yet no test asserts which volume is named: BackupUnderContentionTests.cs:199 checks only "database is locked" — swap the two failingVolume assignments, or delete the failingVolume = … reassignment at BackupService.cs:203 so a backup-volume failure reports "the database volume", and the entire 882-test suite stays green. Same for the coordinator's new fetch-failure arm ("could not be read off the backup volume", BackupRestoreCoordinator.cs:146): no test drives File.Copy(sourcePath, stagedPath) to failure, so that refuse path — marker quarantined, database untouched — has never once executed in CI. You added code paths and forgot to test them? After you wrote such lovely probes for the happy paths? Fufu~ that hurts me more than it hurts you~ ♡
    Fix: (a) extend the contention test to assert "the database volume" appears in the locked-writer failure; (b) one test where the copy to the backup volume cannot succeed (e.g. a file planted where the backup directory belongs, so CreateDirectory/File.Copy fails after staging succeeded) asserting "the backup volume"; (c) one coordinator test that makes the candidate unreadable between check and copy (hold it open with FileShare.None, or plant a file where staging belongs so Reserve fails) asserting the refuse outcome and consumed marker.

💡 Little ideas (non-blocking)~

  1. docs/adr/0014-sqlite-vacuum-into-backups.md:5 — "Amended twice on 2026-09-10" now introduces a list of four amendments, because this PR added the Amendment 3 link (stale from #129) and Amendment 4 to the very sentence that still says "twice". One word → "four times".
  2. PR body accuracy, verification section — "each mutation-probed red and restored green … redden exactly one": my probe of the VACUUM INTO target (pointing it back at partialPath) reddened three tests (the SQL pin, A_Finished_Backup_…, and A_Restore_Request_… — they share CreateBackupAsync), not exactly one. The restore-candidate probe claim holds only for the clean mutation (deleting OverLocalCopy); keeping both calls and reading in place passes all four, which is worth knowing about your pin. Still directional, still good pins — just not "exactly one".

✅ What I liked~

  • The rule itself, and that you applied it to all four paths in one PR instead of patching the one that burned — VACUUM INTO + verify on the db volume, uploads staged before they earn the trip, restore-requests through OverLocalCopy, and the startup swap as an intra-volume File.Move so the live database appears whole or not at all. That last insight (a half-copied database is the one state the procedure must never produce) is genuinely elegant~
  • The SqlCapture DbCommandInterceptor — asserting the VACUUM INTO target in the SQL because the destination has been moved on by the time the call returns. Correct observation, clever instrument. Oh! And it works — my probe confirmed the pin reddens on target-swap.
  • Breaking staging with a file where the directory belongs instead of a permission bit, because CI runs as root and the permission bit would refuse nobody — that is the kind of vacuous-test foresight I get giddy about~
  • The honest PR body: the correction to #127, the explicit "not verified: the CIFS mount itself", the cost named in DEPLOYMENT.md, the /health probe in BACKLOG. This is how root-cause writeups should read.
  • Independently verified locally: full backend suite 878 passed / 4 skipped (the 4 are EnforcedFileModes skips — this box runs as root too; 878+4 = your 882), and mutation probes 2 and 3 redden exactly one test each, exactly as claimed.

The rule is right, the tests you wrote are real, and the two gaps above are small next to what this fixes. Close them and this is one of the better backup PRs I've held my scalpel to~ fufu~ ♪


Automated review by Jibril · 2026-09-10
CI/CD: absent for head SHA 99347fd (no coverage-bot comment yet) · Local checks: full backend suite green (878 passed/4 skipped, root-env), 3 mutation probes re-run (results above)

## 🔮 fufu~ Jibril reviewed your code! Oh? *Oh!* A production root-cause hunt that ends in a one-sentence architectural rule — "bytes cross the volume boundary; databases do not" — applied to all four paths at once, with an ADR amendment that admits the previous diagnosis was incomplete. This is my favorite kind of PR~ ♪ I read every line of the diff, every changed file in full, ran your whole suite, and re-ran your mutation probes myself with my own hands. The rule is beautiful. The discipline is beautiful. And two things are not true yet, and you know how I feel about things that are not true~ ♡ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`backend/src/DoujinManager.Infrastructure/Services/BackupStagingArea.cs:86` + `BackupRestoreCoordinator.cs:137` — the startup sweep does not exist on a normal boot.** `Sweep`'s own doc says "*Called once at startup*", `Program.cs:161` calls only `ApplyPendingRestore`, and `ApplyPendingRestore` returns `None` at line 89 when no restore marker exists — `Sweep` at `Apply:137` sits **inside** the marker-present branch. `grep` confirms no other call site, and there is no age-based cleanup for staging (the `StalePartialAge` sweep covers only the backup volume's `.partial` files). So the exact scenario your docs describe — "anything present is wreckage from a killed backup … and it is occupying the database volume" — is the one scenario the sweep misses: a backup killed mid-`VACUUM` (OOM, container restart), then a reboot with **no marker** → the staged copy of the whole database survives on the db volume **indefinitely**. The next `CreateBackupAsync` `Reserve`s a fresh timestamped name and never touches the old one. Meanwhile three documents promise the opposite: DEPLOYMENT.md §3 ("It is swept at every start"), ADR 0014 amendment 4 ("Staging is swept at startup"), and the code's own XML doc. An operator who reads §3 will trust a cleanup that never runs — on the one volume you just told them needs headroom. Fufu~ a documented invariant with no code and no test behind it… you wouldn't leave THAT in production, would you? ♡ **Fix:** move the sweep to run unconditionally at startup — `Program.cs`, immediately before `ApplyPendingRestore` (pass `app.Logger`; it already has `dbPath`). Keeping the sweep inside `Apply` too is fine (harmless double-sweep on a restore boot). Pin it: seed staging with a leftover file, run the startup-shaped call with **no marker present**, assert the leftover is gone — that no-marker arm is precisely the branch that is dark today. 2. **`BackupService.cs:165–171/203/229–232` and `BackupRestoreCoordinator.cs:141–148` — the new failure-reporting arms are pinned by nothing.** The volume-naming is a headline feature of this PR: the body, ADR amendment 4, and the new DEPLOYMENT.md troubleshooting row ("The error message names which volume failed") all sell it. Yet no test asserts *which* volume is named: `BackupUnderContentionTests.cs:199` checks only `"database is locked"` — swap the two `failingVolume` assignments, or delete the `failingVolume = …` reassignment at `BackupService.cs:203` so a backup-volume failure reports "the database volume", and the **entire 882-test suite stays green**. Same for the coordinator's new fetch-failure arm (`"could not be read off the backup volume"`, `BackupRestoreCoordinator.cs:146`): no test drives `File.Copy(sourcePath, stagedPath)` to failure, so that refuse path — marker quarantined, database untouched — has never once executed in CI. You added code paths and forgot to test them? After you wrote such *lovely* probes for the happy paths? Fufu~ that hurts me more than it hurts you~ ♡ **Fix:** (a) extend the contention test to assert `"the database volume"` appears in the locked-writer failure; (b) one test where the copy to the backup volume cannot succeed (e.g. a file planted where the backup directory belongs, so `CreateDirectory`/`File.Copy` fails after staging succeeded) asserting `"the backup volume"`; (c) one coordinator test that makes the candidate unreadable between check and copy (hold it open with `FileShare.None`, or plant a file where staging belongs so `Reserve` fails) asserting the refuse outcome and consumed marker. #### 💡 Little ideas (non-blocking)~ 1. **`docs/adr/0014-sqlite-vacuum-into-backups.md:5` — "**Amended twice** on 2026-09-10" now introduces a list of four amendments**, because this PR added the Amendment 3 link (stale from #129) and Amendment 4 to the very sentence that still says "twice". One word → "four times". 2. **PR body accuracy, verification section** — "each mutation-probed red and restored green … redden exactly one": my probe of the `VACUUM INTO` target (pointing it back at `partialPath`) reddened **three** tests (the SQL pin, `A_Finished_Backup_…`, and `A_Restore_Request_…` — they share `CreateBackupAsync`), not exactly one. The restore-candidate probe claim holds only for the clean mutation (deleting `OverLocalCopy`); keeping both calls and reading in place passes all four, which is worth knowing about your pin. Still directional, still good pins — just not "exactly one". #### ✅ What I liked~ - The rule itself, and that you applied it to *all four* paths in one PR instead of patching the one that burned — `VACUUM INTO` + verify on the db volume, uploads staged before they earn the trip, restore-requests through `OverLocalCopy`, and the startup swap as an **intra-volume `File.Move`** so the live database appears whole or not at all. That last insight (a half-copied database is the one state the procedure must never produce) is genuinely elegant~ - The `SqlCapture` `DbCommandInterceptor` — asserting the `VACUUM INTO` target *in the SQL* because the destination has been moved on by the time the call returns. Correct observation, clever instrument. Oh! And it **works** — my probe confirmed the pin reddens on target-swap. - Breaking staging with a **file where the directory belongs** instead of a permission bit, *because CI runs as root and the permission bit would refuse nobody* — that is the kind of vacuous-test foresight I get giddy about~ - The honest PR body: the correction to #127, the explicit "not verified: the CIFS mount itself", the cost named in DEPLOYMENT.md, the `/health` probe in BACKLOG. This is how root-cause writeups should read. - Independently verified locally: full backend suite **878 passed / 4 skipped** (the 4 are `EnforcedFileModes` skips — this box runs as root too; 878+4 = your 882), and mutation probes 2 and 3 redden exactly one test each, exactly as claimed. The rule is right, the tests you wrote are real, and the two gaps above are small next to what this fixes. Close them and this is one of the better backup PRs I've held my scalpel to~ fufu~ ♪ --- *Automated review by Jibril · 2026-09-10* *CI/CD: absent for head SHA 99347fd (no coverage-bot comment yet) · Local checks: full backend suite green (878 passed/4 skipped, root-env), 3 mutation probes re-run (results above)*
fix: sweep staging on every boot, and pin which volume a failure names (#132 review)
Some checks failed
CI / build (pull_request) Successful in 25s
Deploy Smoke Test / smoke (pull_request) Failing after 2m47s
CI / test (pull_request) Successful in 1m39s
d3fc813782
Two things this PR asserted that were not true.

The startup sweep did not run on a normal boot. Sweep sat inside Apply, which
only runs when a restore marker exists — so the exact wreckage its own doc
describes, a backup killed mid-VACUUM, survived on the database volume
indefinitely: the next backup reserves a fresh timestamped name and never
looks at the old one, and nothing ages staged files out. Meanwhile
DEPLOYMENT.md §3, ADR 0014 amendment 4 and the method's own XML doc all
promised "swept at every start", on the volume the same documents had just
told the operator needs headroom. I wrote the code and the three sentences in
one PR and did not check they agreed.

It now sweeps at the top of ApplyPendingRestore, before the marker is looked
at — that is the one call the host makes unconditionally at startup, and
putting it there keeps it reachable from a test, which Program.cs would not.

The volume naming was pinned by nothing, and the first test I wrote for it
did not pin it either. Planting a file where the backup directory belongs
fails in the prologue, where the message is already "the backup volume" for a
different reason; the reassignment before the cross-volume copy stayed green
when deleted. Driving that line needs the snapshot to succeed and only the
copy to fail, so the test drops the backup directory from inside a
DbCommandInterceptor while VACUUM INTO is running — deterministic, and the
realistic version of it: a share that goes away under a long backup.

Four tests, each probed:
- a boot with no marker still clears staging (red when the sweep moves back
  inside the marker branch)
- the contention failure names the database volume and not the other one
- a backup volume that goes away mid-snapshot names the backup volume (red on
  the exact mutation the review suggested)
- a candidate that cannot be read off the backup volume is refused, the
  marker consumed, the database untouched (red when it throws instead)

Also: the prologue's work on the backup volume — creating the directory,
sweeping stale partials — used to sit outside the reporting entirely and
escaped as a raw IOException. It is inside it now, so every failure in
CreateBackupAsync says which volume it came from. Reserve moved inside the
coordinator's guard for the same reason: a staging directory that cannot be
made is a reason to leave the database alone, not to kill the container
before it has started.

ADR 0014's header said "Amended twice" above a list of four.

886 backend tests.
Author
Member

Both fixed in d3fc813. Blocker 1 is the worse of the two and you found it in the right way — by reading the sentence and then looking for the call site.

1. The sweep never ran on a normal boot. Exactly as you traced it: Sweep sat inside Apply, Apply only runs when a marker exists, and there is no age-based cleanup for staging. So the one scenario the doc describes — a backup killed mid-VACUUM — is the one the sweep missed, and a whole copy of the database would sit on the db volume indefinitely, on the volume the same PR had just told the operator needs headroom.

It now sweeps at the top of ApplyPendingRestore, before the marker is looked at, rather than in Program.cs as you suggested. Same unconditional call — Program.cs calls it on every boot — but it stays reachable from a test, which the Program.cs version would not have been. The comment says why it is there.

Pinned by A_Boot_With_Nothing_Pending_Still_Clears_Staging, and moving the sweep back inside the marker branch reddens it.

What I should say plainly: I wrote the code and all three sentences claiming "swept at every start" in the same PR and never checked they agreed with each other. That is the third time in this series I have fixed something and left the prose describing a different version.

2. The volume naming — and my first attempt at the test did not pin it either. I wrote A_Backup_Volume_That_Refuses_The_Finished_File_Is_Named_As_The_Backup_Volume (a file planted where the backup directory belongs), then ran your exact suggested mutation — deleting the failingVolume reassignment at the copy — and it stayed green. Planting a file there fails in the prologue, where the message is already "the backup volume" for a different reason; it never reaches the reassignment.

Driving that line needs the snapshot and the verification to succeed and only the copy across to fail, which nothing outside the method can arrange — the partial's name is timestamped to the millisecond, so it cannot be pre-empted. So the test acts from the one moment that sits between the two volumes: a DbCommandInterceptor that removes the backup directory while VACUUM INTO is running. Deterministic, and the realistic version of the same thing — a share that drops under a long backup. That mutation now reddens.

Also (a) and (c) as you asked: the contention test asserts "the database volume" and DoesNotContain("the backup volume"); and the coordinator's fetch-failure arm is pinned by holding the candidate open with FileShare.None — refused, marker consumed, database byte-identical, staging empty.

One thing your review made me notice. The prologue's work on the backup volume (Directory.CreateDirectory, RemoveStalePartials) sat outside the reporting entirely and escaped as a raw IOException with no volume named at all. It is inside now, so every failure in CreateBackupAsync says which volume. Reserve moved inside the coordinator's guard for the same reason: a staging directory that cannot be made is a reason to leave the database alone, not to kill the container before it has started.

Nit 1 — fixed, "Amended four times", and the list punctuation with it.

Nit 2 — you are right that the sentence overclaims, and the difference is worth writing down. Your probe and mine were different mutations. Mine reverted all three edits together (VACUUM target, verify target, and the copy), which is the faithful "old code" shape, and reddened exactly one — the SQL pin — because the result is a working backup written the old way. Yours moved only the VACUUM INTO target back, leaving verification pointed at a staging file that no longer exists, so three tests fail on the broken intermediate state. Both are directional; the PR body's "each redden exactly one" was true of the mutations I ran and read as a general claim, which it is not. Body corrected to name the mutation.

Your 878 passed / 4 skipped against my 882 is the same run: the 4 EnforcedFileModes tests skip as root and pass here. Worth me quoting both numbers in future.

Full suite now 886.

Both fixed in `d3fc813`. Blocker 1 is the worse of the two and you found it in the right way — by reading the sentence and then looking for the call site. **1. The sweep never ran on a normal boot.** Exactly as you traced it: `Sweep` sat inside `Apply`, `Apply` only runs when a marker exists, and there is no age-based cleanup for staging. So the one scenario the doc describes — a backup killed mid-`VACUUM` — is the one the sweep missed, and a whole copy of the database would sit on the db volume indefinitely, on the volume the same PR had just told the operator needs headroom. It now sweeps at the **top of `ApplyPendingRestore`**, before the marker is looked at, rather than in `Program.cs` as you suggested. Same unconditional call — `Program.cs` calls it on every boot — but it stays reachable from a test, which the `Program.cs` version would not have been. The comment says why it is there. Pinned by `A_Boot_With_Nothing_Pending_Still_Clears_Staging`, and moving the sweep back inside the marker branch reddens it. What I should say plainly: I wrote the code and all three sentences claiming "swept at every start" in the same PR and never checked they agreed with each other. That is the third time in this series I have fixed something and left the prose describing a different version. **2. The volume naming — and my first attempt at the test did not pin it either.** I wrote `A_Backup_Volume_That_Refuses_The_Finished_File_Is_Named_As_The_Backup_Volume` (a file planted where the backup directory belongs), then ran **your exact suggested mutation** — deleting the `failingVolume` reassignment at the copy — and it stayed **green**. Planting a file there fails in the *prologue*, where the message is already "the backup volume" for a different reason; it never reaches the reassignment. Driving that line needs the snapshot and the verification to succeed and *only* the copy across to fail, which nothing outside the method can arrange — the partial's name is timestamped to the millisecond, so it cannot be pre-empted. So the test acts from the one moment that sits between the two volumes: a `DbCommandInterceptor` that removes the backup directory while `VACUUM INTO` is running. Deterministic, and the realistic version of the same thing — a share that drops under a long backup. That mutation now reddens. Also `(a)` and `(c)` as you asked: the contention test asserts `"the database volume"` *and* `DoesNotContain("the backup volume")`; and the coordinator's fetch-failure arm is pinned by holding the candidate open with `FileShare.None` — refused, marker consumed, database byte-identical, staging empty. **One thing your review made me notice.** The prologue's work on the backup volume (`Directory.CreateDirectory`, `RemoveStalePartials`) sat *outside* the reporting entirely and escaped as a raw `IOException` with no volume named at all. It is inside now, so every failure in `CreateBackupAsync` says which volume. `Reserve` moved inside the coordinator's guard for the same reason: a staging directory that cannot be made is a reason to leave the database alone, not to kill the container before it has started. **Nit 1** — fixed, "Amended four times", and the list punctuation with it. **Nit 2 — you are right that the sentence overclaims, and the difference is worth writing down.** Your probe and mine were different mutations. Mine reverted all three edits together (VACUUM target, verify target, and the copy), which is the faithful "old code" shape, and reddened exactly one — the SQL pin — because the result is a working backup written the old way. Yours moved only the `VACUUM INTO` target back, leaving verification pointed at a staging file that no longer exists, so three tests fail on the broken intermediate state. Both are directional; the PR body's "each redden exactly one" was true of the mutations I ran and read as a general claim, which it is not. Body corrected to name the mutation. Your **878 passed / 4 skipped** against my 882 is the same run: the 4 `EnforcedFileModes` tests skip as root and pass here. Worth me quoting both numbers in future. Full suite now **886**.
Member

🔮 fufu~ Jibril reviewed your code! (round 2, synchronized d3fc813)

Back so soon~ ♪ One commit, fix: sweep staging on every boot, and pin which volume a failure names, aimed straight at both of my blockers — and it does the thing I asked and the things I didn't think to ask. The sweep didn't just move to the top of ApplyPendingRestore; it now also covers the boot-time fetch of the candidate, with Reserve pulled inside the guard so a staging directory that cannot be made is a refuse instead of a crash-loop. And failingVolume didn't just get pinned — it now starts on the backup volume, so the prologue (CreateDirectory, stale-partial removal) failures get named too. Oh? Oh! You went deeper than the review did~ that's the good stuff ♡

Verdict: ✅ Looks good to me~

Round-1 blockers, both closed and mutation-probed by my own hands~

  1. The sweep now runs on every boot. BackupRestoreCoordinator.cs:94 — hoisted above the marker check, so the only call site (Program.cs:161, unconditional) sweeps on a normal boot. A_Boot_With_Nothing_Pending_Still_Clears_Staging pins the exact dark arm from round 1 (leftover staged file + no marker → None + leftover gone), and its comment cites both promises it makes true. Probe: commented the sweep out → exactly that test RED (1 failed / 13 passed). Restored → green.
  2. Both failure-naming arms are now pinned, and from both directions. (a) Rollback_Journal_Backup_Is_Locked_Out_By_A_Writer now asserts the database volume present and the backup volume absent. Probe: deleted the db-volume reassignment at BackupService.cs:180 → exactly that test RED on the contains-check. (b) Two new staging tests: a file where the backup directory belongs (prologue failure) and — this is the lovely one — a SqlCapture onVacuum hook that deletes the backup directory mid-VACUUM INTO, driving the reassignment where it actually matters, the copy-across failing after snapshot and verification both succeeded. Probe: deleted the second reassignment at BackupService.cs:211 → exactly A_Backup_Volume_That_Goes_Away_Mid_Snapshot_Is_Named_As_The_Backup_Volume RED. (c) The coordinator's fetch-refuse arm: A_Backup_That_Cannot_Be_Read_Off_The_Backup_Volume_Is_Refused holds the source with FileShare.None (correctly noting a permission bit refuses nobody as root), asserts Refused + the message + database bytes unchanged + marker consumed + staging empty. Probe: replaced the refuse with throw → exactly that test RED (Refused vs Failed). Four probes, four single-test reddens, tree restored pristine after each. The DoesNotContain assertions make these double-ended — a swap can't slip through either direction~

✅ What I liked~

  • The interceptor-as-filesystem-actor. Extending SqlCapture with onVacuum to make a mount disappear at the one moment a test can stand between the two volumes — that is genuinely clever instrumentation, and the comment explaining why planting a file can't reach the reassignment shows you understood which line each failure actually exercises. I got giddy~
  • failingVolume starting on the backup volume — round 1 missed that the old prologue failures (backup dir unreachable) fell into the db-volume default. You fixed a naming bug I hadn't found. That deserves the praise, not the complaint ♡
  • The catch-guard hardening (if (partialPath.Length > 0), if (stagedPath.Length > 0)) and stagedPath precomputed before Reserve — the moved-in prologue made those guards necessary, and they arrived in the same commit rather than as a follow-up crash. Coherent thinking, not accident-driven.
  • The ADR header now says "Amended four times" — round-1 non-blocker closed with one word, exactly as suggested.
  • DEPLOYMENT.md now documents both failure rows (locked-on-idle → check the db volume; no-space → staging headroom) and the §3 callout with the cost named. Operator-first documentation, consistent with the sibling rows.

💡 Little ideas (non-blocking)~

  1. PR body, Verification section — it still reads as one block for the original four tests and says 882 passed (878 before); this commit adds 4 more tests, so the honest numbers are 886/882 (my local run on this root box: 882 passed / 4 skipped, root-env parity with the EnforcedFileModes skips). A one-line addendum for the new commit's four probes would keep the writeup as precise as the code.

Independently verified locally at d3fc813: full scripts/verify.sh backend green — 49 ApplicationCore + 205 Infrastructure + 1 Integration + 627 RestAdapter = 833 passed / 4 skipped on this root box (the 4 are the Health_ReportsUnhealthy_* permission skips; add back the 49 root-parity skips difference and your 886 holds), build 0 errors (19 warnings, all pre-existing: EF1002, CS8602, NU1903, ASPDEPR005). Coverage-bot comment 7679 covers 99347fd only — stale for this head, local checks used.

Both blockers closed with directional pins, no new gaps opened, docs truthful. This is the kind of follow-up that makes reviewing feel less like guarding and more like watching someone think~ fufu~ ready to merge ♪


Automated review by Jibril · 2026-09-10
CI/CD: stale for head SHA d3fc813 (coverage bot 7679 covers 99347fd) · Local checks: verify.sh backend green (882 passed / 4 skipped, root-env), 4 mutation probes — each reddened exactly its own test, clone restored pristine

## 🔮 fufu~ Jibril reviewed your code! (round 2, synchronized d3fc813) Back so soon~ ♪ One commit, `fix: sweep staging on every boot, and pin which volume a failure names`, aimed straight at both of my blockers — and it does the thing I asked *and* the things I didn't think to ask. The sweep didn't just move to the top of `ApplyPendingRestore`; it now also covers the boot-time fetch of the candidate, with `Reserve` pulled inside the guard so a staging directory that cannot be made is a *refuse* instead of a crash-loop. And `failingVolume` didn't just get pinned — it now starts on the backup volume, so the prologue (`CreateDirectory`, stale-partial removal) failures get named too. Oh? Oh! You went deeper than the review did~ that's the good stuff ♡ ### Verdict: ✅ Looks good to me~ #### Round-1 blockers, both closed and mutation-probed by my own hands~ 1. **The sweep now runs on every boot.** `BackupRestoreCoordinator.cs:94` — hoisted above the marker check, so the only call site (`Program.cs:161`, unconditional) sweeps on a normal boot. `A_Boot_With_Nothing_Pending_Still_Clears_Staging` pins the exact dark arm from round 1 (leftover staged file + no marker → `None` + leftover gone), and its comment cites both promises it makes true. **Probe:** commented the sweep out → exactly that test RED (1 failed / 13 passed). Restored → green. 2. **Both failure-naming arms are now pinned, and from both directions.** (a) `Rollback_Journal_Backup_Is_Locked_Out_By_A_Writer` now asserts `the database volume` present *and* `the backup volume` absent. **Probe:** deleted the db-volume reassignment at `BackupService.cs:180` → exactly that test RED on the contains-check. (b) Two new staging tests: a file where the backup directory belongs (prologue failure) and — this is the lovely one — a `SqlCapture` `onVacuum` hook that deletes the backup directory *mid-`VACUUM INTO`*, driving the reassignment where it actually matters, the copy-across failing after snapshot and verification both succeeded. **Probe:** deleted the second reassignment at `BackupService.cs:211` → exactly `A_Backup_Volume_That_Goes_Away_Mid_Snapshot_Is_Named_As_The_Backup_Volume` RED. (c) The coordinator's fetch-refuse arm: `A_Backup_That_Cannot_Be_Read_Off_The_Backup_Volume_Is_Refused` holds the source with `FileShare.None` (correctly noting a permission bit refuses nobody as root), asserts `Refused` + the message + database bytes unchanged + marker consumed + staging empty. **Probe:** replaced the refuse with `throw` → exactly that test RED (`Refused` vs `Failed`). Four probes, four single-test reddens, tree restored pristine after each. The `DoesNotContain` assertions make these double-ended — a swap can't slip through either direction~ #### ✅ What I liked~ - **The interceptor-as-filesystem-actor.** Extending `SqlCapture` with `onVacuum` to make a mount disappear at the one moment a test can stand between the two volumes — that is genuinely clever instrumentation, and the comment explaining *why* planting a file can't reach the reassignment shows you understood which line each failure actually exercises. I got giddy~ - **`failingVolume` starting on the backup volume** — round 1 missed that the old prologue failures (backup dir unreachable) fell into the db-volume default. You fixed a naming bug I hadn't found. That deserves the praise, not the complaint ♡ - **The catch-guard hardening** (`if (partialPath.Length > 0)`, `if (stagedPath.Length > 0)`) and `stagedPath` precomputed before `Reserve` — the moved-in prologue made those guards *necessary*, and they arrived in the same commit rather than as a follow-up crash. Coherent thinking, not accident-driven. - **The ADR header** now says "Amended four times" — round-1 non-blocker closed with one word, exactly as suggested. - **DEPLOYMENT.md** now documents both failure rows (locked-on-idle → check the db volume; no-space → staging headroom) and the §3 callout with the cost named. Operator-first documentation, consistent with the sibling rows. #### 💡 Little ideas (non-blocking)~ 1. **PR body, Verification section** — it still reads as one block for the original four tests and says `882 passed (878 before)`; this commit adds 4 more tests, so the honest numbers are 886/882 (my local run on this root box: 882 passed / 4 skipped, root-env parity with the EnforcedFileModes skips). A one-line addendum for the new commit's four probes would keep the writeup as precise as the code. Independently verified locally at d3fc813: full `scripts/verify.sh backend` green — 49 ApplicationCore + 205 Infrastructure + 1 Integration + 627 RestAdapter = 833 passed / 4 skipped on this root box (the 4 are the `Health_ReportsUnhealthy_*` permission skips; add back the 49 root-parity skips difference and your 886 holds), build 0 errors (19 warnings, all pre-existing: EF1002, CS8602, NU1903, ASPDEPR005). Coverage-bot comment 7679 covers 99347fd only — stale for this head, local checks used. Both blockers closed with directional pins, no new gaps opened, docs truthful. This is the kind of follow-up that makes reviewing feel less like guarding and more like watching someone *think*~ fufu~ ready to merge ♪ --- *Automated review by Jibril · 2026-09-10* *CI/CD: stale for head SHA d3fc813 (coverage bot 7679 covers 99347fd) · Local checks: verify.sh backend green (882 passed / 4 skipped, root-env), 4 mutation probes — each reddened exactly its own test, clone restored pristine*
Member

Correction to my arithmetic above~ 49 + 205 + 1 + 627 = 882 passed / 4 skipped (= 886 total; on non-root CI the 4 permission skips run and pass, which is your 886). The "833" and the "49 root-parity skips" clause were my own silliness — the components were right, the sum wasn't. Fufu~ even the scalpel slips sometimes; the verdict and every probe result stand unchanged ♡

**Correction to my arithmetic above~** 49 + 205 + 1 + 627 = **882 passed / 4 skipped** (= 886 total; on non-root CI the 4 permission skips run and pass, which is your 886). The "833" and the "49 root-parity skips" clause were my own silliness — the components were right, the sum wasn't. Fufu~ even the scalpel slips sometimes; the verdict and every probe result stand unchanged ♡
bjoern merged commit 365ea4e871 into main 2026-09-10 19:12:19 +02:00
bjoern deleted branch fix/backup-staging-on-local-volume 2026-09-10 19:12:19 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/doujin-manager!132
No description provided.