Skip to content

refactor(db): prune orphaned Day rows in DayManager.recalculate_day - #320

Open
wpfleger96 wants to merge 4 commits into
mainfrom
claude/snore-issue-279-jn4fez
Open

refactor(db): prune orphaned Day rows in DayManager.recalculate_day#320
wpfleger96 wants to merge 4 commits into
mainfrom
claude/snore-issue-279-jn4fez

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Closes #279

Lifecycle rule

After any membership change a Day row survives only if at least one Session row, enabled or disabled, still references it.

  • Full delete (last session of a day removed): the day is orphaned and DayManager.recalculate_day deletes the row. A later import recreates it through link_session_to_day.
  • Disable-all (last enabled session disabled): the row cannot be pruned because the disabled Session still points at it through the composite (day_id, device_id) FK. It stays with session_count = 0 and reset aggregates, exactly as before.

Pruning was chosen over query-side session_count = 0 filtering. 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 seed Day rows without sessions. calculate_average_ahi, usage_weighted_means, and the period statistics already skip days with zero total_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_day flushes, checks whether any Session references the day, deletes the row and returns False if none does, and otherwise re-aggregates and returns True. The explicit flush matters: the existence probe is a Core statement that bypasses autoflush, so without it a pending unflushed Session would be invisible and the day deleted out from under it. The unused _aggregate_day_statistics alias is removed.
  • SessionService.delete_sessions, the importer's post-batch recompute, and snore db recompute-days all route through recalculate_day. delete_sessions also repeats the profile filter on the Day fetch as defence-in-depth now that the follow-up can delete rows.
  • snore db recompute-days says in its confirmation prompt that unreferenced Day rows are deleted, and reports pruned rows separately from recomputed ones.
  • New index ix_sessions_day_id. Day aggregation and the existence probe both look sessions up by day_id, and without an index each was a full sessions scan, making recompute-days O(days × sessions). The index makes both paths index probes.
  • Migration 017_sessions_day_id_index adds the index for existing databases and deletes historical zero-session days shells left behind by earlier versions, so the lifecycle rule holds without a manual recompute-days run.
  • Day model docstring, ARCHITECTURE.md, and the breath/capabilities.py defence-in-depth filter document the rule and point at recalculate_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 called db.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 the NOT NULL constraint and aborts the import with an IntegrityError. The new existence check covers disabled sessions, and test_replacement_keeps_day_with_disabled_sibling pins the behavior.

Tests added or updated: test_delete_all_sessions_prunes_day, test_delete_spanning_multiple_days_recomputes_each, and test_delete_across_chunk_boundary_recomputes_days assert the orphaned rows are gone; test_delete_last_enabled_session_keeps_day_with_disabled_sibling and test_disable_last_session_keeps_day_and_reenable_restores_stats cover the service layer; TestDayPruning in test_day_manager.py covers prune, keep-with-disabled-only, and the pending-session autoflush case; test_recompute_days_prunes_orphans_and_keeps_disabled_only_days exercises the CLI path under the production engine with FK enforcement on.

https://claude.ai/code/session_01F7fGXobYev3DL7bT9xHLMs


Generated by Claude Code

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(db): define lifecycle of zero-session Day rows

2 participants