refactor(db): prune orphaned Day rows in DayManager.recalculate_day - #320
Open
wpfleger96 wants to merge 4 commits into
Open
refactor(db): prune orphaned Day rows in DayManager.recalculate_day#320wpfleger96 wants to merge 4 commits into
wpfleger96 wants to merge 4 commits into
Conversation
A Day row now exists only while at least one Session row, enabled or disabled, references it. Deleting the last session prunes the row; disabling the last enabled session keeps it with session_count == 0, since the disabled session's composite FK still points at it. The importer previously pruned on session_count == 0, which counts only enabled sessions. With the composite FK declared ondelete=CASCADE, that would have cascade-deleted a disabled sibling session whenever the day's only enabled session was replaced. Routing the importer, delete_sessions, and db recompute-days through one existence check removes that hazard and gives existing databases a path to prune pre-existing shell rows. Closes #279 Claude-Session: https://claude.ai/code/session_01F7fGXobYev3DL7bT9xHLMs
…hells The existence probe in DayManager.recalculate_day is a Core statement and does not trigger autoflush, so a pending Session referencing the day was invisible and the day could be deleted out from under it. Flush first. sessions.day_id had no index, so both day aggregation and the probe were full sessions scans and recompute-days was O(days x sessions). Migration 017 adds ix_sessions_day_id and deletes the zero-session Day shells that earlier versions left behind, so the lifecycle rule holds for existing databases without a manual recompute-days run. recompute-days now states in its prompt that unreferenced Day rows are deleted and reports pruned rows separately; delete_sessions repeats the profile filter on the Day fetch as defence-in-depth; the unused _aggregate_day_statistics alias is removed. Claude-Session: https://claude.ai/code/session_01F7fGXobYev3DL7bT9xHLMs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #279
Lifecycle rule
After any membership change a
Dayrow survives only if at least oneSessionrow, enabled or disabled, still references it.DayManager.recalculate_daydeletes the row. A later import recreates it throughlink_session_to_day.Sessionstill points at it through the composite(day_id, device_id)FK. It stays withsession_count = 0and reset aggregates, exactly as before.Pruning was chosen over query-side
session_count = 0filtering. The importer already pruned orphans on the replace path, so this completes that choice rather than introducing a second mechanism, and read-side filtering would have changed semantics at roughly ten query sites plus the many fixtures that seedDayrows without sessions.calculate_average_ahi,usage_weighted_means, and the period statistics already skip days with zerototal_therapy_hours, so the surviving disable-all shells do not distort weighted averages. One consequence: requesting a fully deleted day by date now returns not-found instead of a zeroed row.Changes
DayManager.recalculate_dayflushes, checks whether anySessionreferences the day, deletes the row and returnsFalseif none does, and otherwise re-aggregates and returnsTrue. The explicit flush matters: the existence probe is a Core statement that bypasses autoflush, so without it a pending unflushedSessionwould be invisible and the day deleted out from under it. The unused_aggregate_day_statisticsalias is removed.SessionService.delete_sessions, the importer's post-batch recompute, andsnore db recompute-daysall route throughrecalculate_day.delete_sessionsalso repeats the profile filter on theDayfetch as defence-in-depth now that the follow-up can delete rows.snore db recompute-dayssays in its confirmation prompt that unreferencedDayrows are deleted, and reports pruned rows separately from recomputed ones.ix_sessions_day_id. Day aggregation and the existence probe both look sessions up byday_id, and without an index each was a fullsessionsscan, makingrecompute-daysO(days × sessions). The index makes both paths index probes.017_sessions_day_id_indexadds the index for existing databases and deletes historical zero-sessiondaysshells left behind by earlier versions, so the lifecycle rule holds without a manualrecompute-daysrun.Daymodel docstring,ARCHITECTURE.md, and thebreath/capabilities.pydefence-in-depth filter document the rule and point atrecalculate_day.Latent bug removed
The importer's previous prune condition was
session_count == 0, which counts enabled sessions only. Replacing a day's only enabled session while a disabled sibling remained would have calleddb.delete(day_record)with a live child. The ORM does not fall through to the database cascade in that case: it loads the child collection and tries to null the sibling's(day_id, device_id)FK, which fails theNOT NULLconstraint and aborts the import with anIntegrityError. The new existence check covers disabled sessions, andtest_replacement_keeps_day_with_disabled_siblingpins the behavior.Tests added or updated:
test_delete_all_sessions_prunes_day,test_delete_spanning_multiple_days_recomputes_each, andtest_delete_across_chunk_boundary_recomputes_daysassert the orphaned rows are gone;test_delete_last_enabled_session_keeps_day_with_disabled_siblingandtest_disable_last_session_keeps_day_and_reenable_restores_statscover the service layer;TestDayPruningintest_day_manager.pycovers prune, keep-with-disabled-only, and the pending-session autoflush case;test_recompute_days_prunes_orphans_and_keeps_disabled_only_daysexercises the CLI path under the production engine with FK enforcement on.https://claude.ai/code/session_01F7fGXobYev3DL7bT9xHLMs
Generated by Claude Code