Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,8 @@ created_at, updated_at
UNIQUE(device_id, date)
```

After any membership change a `days` row survives only if at least one `sessions` row (enabled or disabled) still references it; `DayManager.recalculate_day` prunes orphaned rows after session deletion, import-time replacement, and `snore db recompute-days` (migration `017` removed shells left by earlier versions). `session_count` counts enabled sessions, so a day whose sessions are all disabled persists with `session_count = 0` and reset aggregates. Requesting a fully deleted day by date therefore returns not-found rather than a zeroed row.

**sessions**
```sql
id, device_id (FK devices), day_id (FK days),
Expand Down
17 changes: 13 additions & 4 deletions src/snore/cli/groups/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,19 @@ async def _run() -> None:
@db.command("recompute-days")
@db_option
@click.confirmation_option(
prompt="Re-derive every Day's metrics from stored session statistics?"
prompt=(
"Re-derive every Day's metrics from stored session statistics "
"and delete Day rows no session references?"
)
)
def recompute_days(db: str | None) -> None:
"""Recompute all Day aggregates from stored session statistics (no reparse).

Re-runs day aggregation over the ``Statistics`` already stored for each
session, refreshing every Day row across all devices and profiles. Use
after an aggregation-formula change (e.g. a new weighting) to update
historical Day rows without re-importing raw device data.
historical Day rows without re-importing raw device data. Day rows that
no session references are deleted rather than recomputed.

Days are processed in chunks, each committed in its own gated write
transaction (like ``cleanup-orphans``), so the SQLite write lock is
Expand Down Expand Up @@ -218,6 +222,7 @@ async def _run() -> None:
console=console,
) as progress:
task = progress.add_task("Recomputing days", total=len(day_ids))
pruned = 0
for chunk in iter_id_chunks(day_ids):
async with write_gate(), session_scope(immediate=True) as session:
days = (
Expand All @@ -226,10 +231,14 @@ async def _run() -> None:
.all()
)
for day in days:
await DayManager.aggregate_day_statistics(day, session)
if not await DayManager.recalculate_day(day, session):
pruned += 1
progress.update(task, advance=len(chunk))

print_success(f"Recomputed {len(day_ids)} day(s) from session statistics")
summary = f"Recomputed {len(day_ids) - pruned} day(s) from session statistics"
if pruned:
summary += f"; pruned {pruned} orphaned day(s)"
print_success(summary)

asyncio.run(_run())

Expand Down
33 changes: 26 additions & 7 deletions src/snore/database/day_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from datetime import date, datetime, time, timedelta

from sqlalchemy import select
from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload

Expand Down Expand Up @@ -220,10 +220,6 @@ async def aggregate_day_statistics(cls, day: Day, db_session: AsyncSession) -> N
day, spec.name, cls._weighted_average(stat_pairs, spec.name)
)

# Private alias kept for existing callers (importers.py, tests) that still
# use the pre-rename name.
_aggregate_day_statistics = aggregate_day_statistics

@classmethod
async def link_session_to_day(
cls,
Expand Down Expand Up @@ -255,12 +251,35 @@ async def link_session_to_day(
return day

@classmethod
async def recalculate_day(cls, day: Day, db_session: AsyncSession) -> None:
async def recalculate_day(cls, day: Day, db_session: AsyncSession) -> bool:
"""
Recalculate aggregated statistics for a day.
Recalculate a day after its session membership changed.

Lifecycle rule: after any membership change a Day row survives only if
at least one Session row, enabled or disabled, still references it.
Deleting the last such session orphans the day and the row is pruned
here (re-import recreates it via ``link_session_to_day``). Disabling
the last enabled session does not orphan the day: the disabled Session
still points at it through the composite ``(day_id, device_id)`` FK, so
the row stays with ``session_count == 0`` and reset aggregates.

Args:
day: Day object to recalculate
db_session: SQLAlchemy async database session

Returns:
True if the day still exists, False if it was pruned.
"""
# The existence probe is a Core statement and does not trigger
# autoflush, so flush first: a pending Session that references this
# day would otherwise be invisible and the day deleted from under it.
await db_session.flush()
referenced = await db_session.scalar(
select(exists().where(SessionModel.day_id == day.id))
)
if not referenced:
await db_session.delete(day)
await db_session.flush()
return False
await cls.aggregate_day_statistics(day, db_session)
return True
9 changes: 3 additions & 6 deletions src/snore/database/importers.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,12 +532,9 @@ async def import_sessions_batch(
for day_id in batch_day_ids:
day_record = await db.get(models.Day, day_id)
if day_record:
await DayManager._aggregate_day_statistics(day_record, db)
# A replaced session may have been the sole occupant of its
# Day row. Delete orphan Day rows so they don't appear in
# day listings with zero sessions.
if day_record.session_count == 0:
await db.delete(day_record)
# A replaced session may have been the sole occupant of
# its Day row; recalculate_day prunes the orphaned row.
await DayManager.recalculate_day(day_record, db)

# Force and overlap-replace imports delete existing sessions (cascading
# to their waveforms) before inserting replacements, so SQLite may reuse
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Index ``sessions.day_id`` and delete orphaned ``days`` rows.

``DayManager.aggregate_day_statistics`` and the orphan probe in
``DayManager.recalculate_day`` both look sessions up by ``day_id``. Without an
index each is a full ``sessions`` scan, so ``snore db recompute-days`` costs
O(days x sessions). The model-side ``Index`` covers fresh DBs via
``create_all``; this migration covers pre-existing DBs. Both use the name
``ix_sessions_day_id`` so the ``test_migration_schema_drift`` parity check
stays green.

Before ``recalculate_day`` pruned orphans, deleting every session of a day
left a zero-session ``days`` shell behind. The one-off DELETE below removes
those historical shells so the lifecycle rule holds for existing databases
without a manual ``recompute-days`` run. Days whose sessions are all disabled
are still referenced and are kept.

Revision ID: 017_sessions_day_id_index
Revises: 016_validation_runs
"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "017_sessions_day_id_index"
down_revision: str | Sequence[str] | None = "016_validation_runs"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

_INDEX = "ix_sessions_day_id"
_TABLE = "sessions"


def upgrade() -> None:
bind = op.get_bind()
from sqlalchemy import inspect as sa_inspect # noqa: PLC0415

existing_indexes = {ix["name"] for ix in sa_inspect(bind).get_indexes(_TABLE)}
if _INDEX not in existing_indexes:
op.create_index(_INDEX, _TABLE, ["day_id"])

op.execute(
sa.text(
"DELETE FROM days WHERE id NOT IN "
"(SELECT day_id FROM sessions WHERE day_id IS NOT NULL)"
)
)


def downgrade() -> None:
# The orphan-row DELETE is not reversible; only the index is dropped.
bind = op.get_bind()
from sqlalchemy import inspect as sa_inspect # noqa: PLC0415

existing_indexes = {ix["name"] for ix in sa_inspect(bind).get_indexes(_TABLE)}
if _INDEX in existing_indexes:
op.drop_index(_INDEX, table_name=_TABLE)
10 changes: 9 additions & 1 deletion src/snore/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,11 @@ def __repr__(self) -> str:


class Day(Base):
"""Daily aggregated statistics (OSCAR-compatible pre-calculated cache)."""
"""Daily aggregated statistics (OSCAR-compatible pre-calculated cache).

Rows no Session references are pruned by ``DayManager.recalculate_day``,
which documents the lifecycle rule.
"""

__tablename__ = "days"

Expand Down Expand Up @@ -730,6 +734,10 @@ class Session(Base):
# start_time range scan. Run per imported session; index prevents full table
# scan growth as diagnostic-blip sessions accumulate.
Index("ix_sessions_device_id_start_time", "device_id", "start_time"),
# Day aggregation and the orphan-day probe both look sessions up by
# day_id; without this index each is a full sessions scan, making
# ``db recompute-days`` O(days x sessions).
Index("ix_sessions_day_id", "day_id"),
)

def __repr__(self) -> str:
Expand Down
3 changes: 2 additions & 1 deletion src/snore/services/breath/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ async def get_device_capabilities(
)

# Date range of actual data — only days with at least one Session count
# as "imported nights"; empty Day rows never widen the reported range.
# as "imported nights". DayManager.recalculate_day prunes orphaned Day
# rows, so this predicate is defence-in-depth against hand-edited data.
day_stmt = select(models.Day).where(
models.Day.device_id == device_id,
exists().where(models.Session.day_id == models.Day.id),
Expand Down
9 changes: 7 additions & 2 deletions src/snore/services/session_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,8 @@ async def delete_sessions(self, session_ids: list[int]) -> int:

Day aggregates for the affected days are recalculated after the DELETE
(mirrors ``set_session_enabled``), so a day left with fewer sessions is
re-aggregated and a day left with none has its statistics reset.
re-aggregated and a day left with no sessions at all is pruned (see
``DayManager.recalculate_day``).
"""
# Dedupe: chunked IN-binds don't implicitly de-duplicate like a single IN.
session_ids = list(dict.fromkeys(session_ids))
Expand Down Expand Up @@ -405,11 +406,15 @@ async def delete_sessions(self, session_ids: list[int]) -> int:
# a reused id never serves a deleted row's arrays.
clear_waveform_array_cache()

# day_ids were collected under the profile filter; the join repeats it
# as defence-in-depth because recalculate_day can delete the row.
for chunk in iter_id_chunks(list(day_ids)):
days = (
(
await self.db_session.execute(
select(models.Day).where(models.Day.id.in_(chunk))
select(models.Day)
.join(models.Device, models.Day.device_id == models.Device.id)
.where(models.Day.id.in_(chunk), self._profile_filter())
)
)
.scalars()
Expand Down
54 changes: 54 additions & 0 deletions tests/integration/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,60 @@ def test_recompute_days_rederives_ahi(
assert len(ahi_values) == 10
assert all(v == pytest.approx(5.2) for v in ahi_values)

def test_recompute_days_prunes_orphans_and_keeps_disabled_only_days(
self, cli_runner, populated_test_db, db_session
):
"""Under the production engine (FK enforcement on), recompute-days
deletes a Day whose sessions are gone and keeps a Day whose only
session is disabled, reporting the pruned count."""
orphan_day_id, disabled_day_id = (
db_session.execute(
text(
"SELECT day_id FROM sessions WHERE device_session_id IN "
"('test_session_0', 'test_session_1') ORDER BY device_session_id"
)
)
.scalars()
.all()
)
db_session.execute(
text("DELETE FROM sessions WHERE device_session_id = 'test_session_0'")
)
db_session.execute(
text(
"UPDATE sessions SET enabled = 0 "
"WHERE device_session_id = 'test_session_1'"
)
)
db_session.commit()

result = cli_runner.invoke(
cli,
["db", "recompute-days", "--db", str(populated_test_db)],
input="y\n",
)

assert result.exit_code == 0
assert "Recomputed 9 day(s)" in result.output
assert "pruned 1 orphaned day(s)" in result.output

db_session.expire_all()
remaining = db_session.execute(text("SELECT id FROM days")).scalars().all()
assert orphan_day_id not in remaining
assert len(remaining) == 9
disabled_count = db_session.execute(
text("SELECT session_count FROM days WHERE id = :id"),
{"id": disabled_day_id},
).scalar_one()
assert disabled_count == 0
assert (
db_session.execute(
text("SELECT COUNT(*) FROM sessions WHERE day_id = :id"),
{"id": disabled_day_id},
).scalar_one()
== 1
)

def test_recompute_days_empty_database(self, cli_runner, temp_db):
"""recompute-days on an empty database reports zero days."""
asyncio.run(init_database(str(temp_db)))
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/test_breath_service_seams.py
Original file line number Diff line number Diff line change
Expand Up @@ -988,7 +988,8 @@ async def test_day_with_no_sessions_returns_no_data_in_range(
"""get_device_capabilities returns NO_DATA_IN_RANGE for a Day row with no sessions.

Empty Day cache rows (session_count=0, no Session children) must not inflate
nights_with_data or produce a non-null actual date range.
nights_with_data or produce a non-null actual date range. DayManager now
prunes such rows, so this models legacy or hand-edited data.
plan §13 lines 949-961: actual endpoints derived from dates with ≥1 session.
"""
from snore.database import models as _m # noqa: PLC0415
Expand Down
Loading