feat: Phase 1 · 8/8 — backups #12
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/p1-backups"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Slice 8 of Phase 1, the last: the rotating daily backups and the Backups tab (
docs/stories/settings.md:31,docs/REQUIREMENTS.md:153; ADRs 0005, 0006, 0007, 0008, 0011 and the new ADR 0026, which pins what Kagura ADR 0032 leaves to the implementer). One commit,fa226ad.What's in
Infrastructure/Backups/):BackupScheduleris aBackgroundServiceon the injectedTimeProvider— one run a day at a local wall time (TSUMUGI_BACKUP_SCHEDULE,HH:mm, default 03:00, with the day's UTC offset so DST moves the offset, not the wall time). On start, if no archive is stamped at or after the most recent slot, one run follows a five-minute grace (a test host or a crash-looping container never writes; a box that slept through the slot still gets its backup). A refused or crashing run is logged and the next slot still runs; a dead backups volume at boot skips the catch-up and still schedules; nothing can stop the host.BackupWriterruns one cycle at a time (SemaphoreSlim, so the job and a click queue):VACUUM INTOon a connection of its own (Mode=ReadWrite, so a wrong path is a failure, not a fresh empty database "backed up"; the WAL's committed pages are folded in) into the container's temp dir, thentsumugi.db+ every keyring file askeys/…+manifest.txt(timestamp,appVersionfrom MinVer,userVersion,migration=the last EF migration of the snapshot, thefile=list) as.zip.tmpinTSUMUGI_BACKUP_DIR, moved intotsumugi-backup-YYYYMMDD-HHmmss.zip(UTC,-Non a same-second collision), then a.sha256sidecar in thesha256sum -cform. Only after that, archives older thanTSUMUGI_BACKUP_RETENTION_DAYS(default 7, strict<) are removed — by the stamp in their names, never by a file time (a network volume reports 1601 for everything); unparseable names are kept; orphan sidecars and stale*.tmpare swept; the log names the pruned count.GET /backups/snapshot, gated by the fallback policy like every page (noAllowAnonymous): a freshVACUUM INTOcopy of the database alone, never the keyring, streamed astsumugi-YYYYMMDD-HHmmss.dbfrom a temp file withDeleteOnClose.BackupsSection, fourth tab,?tab=backups): Last backup (StatusDot + RelativeTime, or Never), the schedule ("Daily at 03:00, server time"), the retention, a hint that restore is a volume swap; Download snapshot (aButton Href="backups/snapshot" Download— relative, so a sub-path works, and thedownloadattribute keeps Blazor's router off it) and Back up now withLoading; a Kept archives card with anEmptyStateor aTable(archive · taken · size); anInlineAlertfor a refused run. One Fluxor slice (BackupsSettingsState, five action records, reducers, effects over the two use cases); noEntityKinds/doorbell on purpose — archives are files, unjournaled — the tab re-reads on mount and after its own run.IBackupStore+BackupArtifact/BackupPolicy/BackupStatusView/DatabaseSnapshot+GetBackupStatus/RunBackupNow/DownloadDatabaseSnapshotin UseCases;BackupOptions,BackupArtifactName(the one place the name is spelled,GeneratedRegex),SqliteSnapshot,BackupWriter,FileSystemBackupStore(every I/O or SQLite failure becomes anErrwith a message),BackupSchedule(pure slot arithmetic),BackupSchedulerin Infrastructure — registered byAddInfrastructure(databasePath, backupOptions)only when the host hands in its directories, all singletons over options and the clock;BackupConfigurationin the Server reads the two keys and fails start-up by name for a schedule that is notHH:mmor a retention below one (TimeOnly.TryParseExact, neverTimeSpan.TryParse, where"3"is three days). One package added:Microsoft.Extensions.Hosting.Abstractionsfor Infrastructure.docker-compose.ymlgain the two keys;settings.md:31andREQUIREMENTS.md:153ticked;AGENTS.mdandREADME.mdsay Phase 1 is complete and Phase 2 is next; the settings page's comment says where Transcripts lands.Deferred and why
settings.md:32stays unticked.Tests
DependencyInjectionTestslists the new port)New:
BackupWriterTests(12, over a real SQLite file: the archive's entries, the copy opening on its own with the rows and no-walbeside it, rows still in the WAL folded in, the manifest incl.migration=20260922165748_AddAppSettings, the sidecar recomputed, a missing keyring not a failure, distinct names in one second and a half-written zip stepped over, pruning by the name stamp with the 7-day file kept and garbage names kept, orphan sidecars and stale tmp swept, a failed snapshot and a failure after the snapshot both keeping every older archive, concurrent runs both landing, the name parser),FileSystemBackupStoreTests(4),BackupScheduleTests(4, Berlin time incl. the DST change),BackupSchedulerTests(5, on aFakeTimeProvider: the grace, no catch-up with a fresh archive and tomorrow's slot to the second, daily repeats with a refusal and a crash in between, a prompt stop, a dead directory at boot),BackupConfigurationTests(10 cases incl. the host crashing by name),BackupSnapshotEndpointTests(3: gated, a named SQLite file when signed in, the scheduler running over the factory's directories and writing nothing at start-up),BackupsSettingsTests(3),BackupsSectionTests(5: never/empty/link/button, the list with sizes from 512 B to GB and the injected clock, the spinner then the row, a refusal, the policy),SettingsPageTests+1 (the deep link),AppChromeTests(four tabs). Doubles:FakeBackupStorewired intoFakeWorld/AdapterTestContext; aRecordingBackupStorefor the scheduler. The scheduler tests drive the loop directly (RunLoopAsync): aBackgroundServicestarts its loop asynchronously, so a fake clock advanced right afterStartAsyncruns before the first timer exists — the stop test still goes throughStartAsync/StopAsync.Mutation probes (each disabled by hand, the named test fails, restored): prune
<→<=; prune moved before the write (caught by the failure-after-snapshot test, which was added when the first attempt showed the missing-database test could not see the ordering);IsDueinverted; the run'scatchremoved;DeleteOnClosedropped;ReadWriteCreateon the source;Running = falsedropped from the refusal reducer;.AllowAnonymous()on the endpoint; themigration=line dropped;Downloaddropped from the link. Inconclusive: the serialisation removed — both runs still land in order on a fast disk. Not run: "TryParse accepting garbage" — the regex and the date parse are two guards; no one-line mutation defeats both.Coverage (coverlet, merged across test projects, paths normalised)
Infrastructure/Backups/**,UseCases/Backups/**,Settings/Backups*,Server/Backups/**,Infrastructure/DependencyInjection.cs,Program.cs: 332 / 338 lines, 126 / 138 branches. Not covered, and why:SqliteSnapshot.cs43–45, 57–59 — thecatcharms of the manifest reads: an unreadable snapshot means theVACUUM INTOalready failed; 55, 74 — an empty migrations table and the null/DBNullarms of the scalar read.BackupWriter.cs54–56 — the half-written zip's cleanup when the failure came after the temp file was created (the ordering test makes the temp path a directory, which the cleanup rightly does not see); 114 —?? "unknown"on the version attribute MinVer always stamps.BackupScheduler.cs34, 41 — the loop's condition only ends by cancellation inside the wait;BackupSchedule.cs19 — one short-circuit combination ofIsDue.Program.cs27, 39, 106 — configuration fallbacks and the seed log's ternary, as before.Browser-verified (Release build, Chrome via Playwright, the seed, light and dark)
Sign in →
settings?tab=backups(the deep link the story asks for; the pass drove the tab through it) → Backups active, Last backup · Never with the warning dot, "Daily at 01:53, server time" (the schedule was set three minutes ahead), "7 days", No archive yet → Back up now → the spinner, thentsumugi-backup-20260922-235013.zip · just now · 14.5 KBand Last backup · just now with the green dot → Download snapshot → Playwright's download event,tsumugi-20260922-235013.db, first bytesSQLite format 3, two stories inside (Mira's Winter, The Lantern Road), a single file → the shell: the zip and its.sha256,sha256sum -c→OK,unzip -l→tsumugi.db,keys/key-…xml,manifest.txt; the manifest carriesappVersion=0.1.1-dev.0.6+f4c4a3c…andmigration=20260922165748_AddAppSettings→ the log: "No archive covers the last 01:53 slot; a backup runs in 00:05:00", then five minutes after boot "Backup written … pruned 0 older archive(s)" and "Daily backup written", then "Next backup at 2026-09-24T01:53:00+02:00" → reload: two rows, just now and 5 minutes ago → restart with the archives deleted and a schedule ten minutes in the past → the catch-up line again → restart withTSUMUGI_BACKUP_SCHEDULE=3→ the process dies with "TSUMUGI_BACKUP_SCHEDULE must be a local time as HH:mm, not '3'" → dark mode: the populated tab, its two header buttons verified enabled.Follow-ups
🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
The last slice of Phase 1 — the one that guards every word ever written~ I came to this one with my knives out, scarlet. Rotating backups, a scheduler on a fake clock, a VACUUM INTO with WAL-folding, prune-by-name-stamp… and oh? Oh! This is wonderful~ The older rounds' lessons are all here: the stale-binary trap, the ordering trap, the probe-or-it-didn't-happen rule — every claim in the body has a test behind it, and I brought my own scalpels to check. ♪
Verdict: ✅ Looks good to me~
Local verification (CI absent for
fa226ad— no bot comment yet): build 0 warnings / 0 errors under-warnaserror; full suite 403/403 matching the body's table exactly — Domain 48, UseCases 16, Adapter 189, Integration 150. (One transient: a pre-existing PR #11DocumentsPanelTestsbUnit wait-timeout flaked once under load here, passes isolated — same known class as before, not this PR's code.)My own mutation probes — each red on exactly its own tests, each restored (tree verified clean, rebuilt green):
<→<=→Pruning_goes_by_the_name_stampred (the 7-day edge holds: retention+1 stands).IsDueinverted → 3 red (the grace test, the no-catch-up test, the due-semantics pins — including the same-instant-other-offset case, fufu, you even pinned that).BackupCompletedreducer'sArtifactsre-read dropped → the reducer test and the widget test red. The button can't show a stale list.(Yes, the stale-binary trap bit me on probe 1 — infrastructure build only, old DLL in the test bin, exactly as your rounds documented. Rebuilt the test project, got the honest red. fufu~ your own medicine, tasted~)
Design, traced:
EnsureLoadedon mount mirrorsProviderSection; effects without try/catch mirrorsProviderSettingsEffects; the no-doorbell/no-EntityKindschoice is documented in both places and consistent with ADR 0022's unjournaled rule. One Fluxor slice, five actions, four reducers — the established shape.BackgroundService-kill the host — pinned by the refuse+throw+ok test), the dead-volume-at-boot catch (skip catch-up, still schedule), and the store'sErrconversion overIOException/UnauthorizedAccessException/SqliteException— each with its own test.SqliteOpenMode.ReadWriteon the source — a wrong path fails instead of "backing up" a fresh empty database — is the kind of detail I get genuinely giddy over. And the escaping of'in theVACUUM INTOliteral is correct;Mode=ReadWrite+command.CommandTimeoutreasoning holds.Button Href+Downloadverified against Kagaku.UI's own markup (download=""on the anchor,aria-busyonly on buttons);RelativeTime'sNowparameter exists precisely for the injected-clock assertions you make. No shared byte formatter exists in Kagaku.UI to reuse — the localSize()is not a DRY violation.-Nsame-second collision, orphan-sidecar and stale-tmp sweeps, strict-<retention, unparseable-names-kept — all pinned by tests I watched die under mutation.ReturnUrlpinned), named SQLite download with header verified,DeleteOnClosedisposal pinned, and the scheduler-over-the-factory test proves the grace keeps every test host silent at startup. Clever~ ♡💡 Little ideas (non-blocking)~
BackupStatusView.Latest— consumed only by its own tests today; the tab readsArtifacts[0]directly. Harmless convenience on a read model — leave it, or let the tab use it someday so it has one meaning in one place.Size()in BackupsSection — when a third consumer appears (Phase 2 transcripts?), a shared Kagaku.UI byte formatter would be the moment; two implementations is not yet a smell.✅ What I liked~
?? "unknown", the loop-condition lines) is genuinely unreachable without breaking something else first, and the two "not run" mutation admissions are correctly reasoned rather than hand-waved.RunLoopAsyncdirectly with the fake-clock caveat written in the test file — the documentation lives where the next person will trip over it.Phase 1 complete, then~ All eight slices, each one reviewed, each one mine~ ♡ Go merge it before I change my mind about giving this back.
Automated review by Jibril · 2026-09-23
CI/CD: absent for head SHA
fa226ad· Local checks: build 0w/0e −warnaserror; 403/403 (48+16+189+150); 4 mutation probes red-on-target, restored pristine