"""Historical local-day migration and repair acceptance tests for issue #99.""" import sqlite3 import sys from datetime import datetime, timezone from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from fenris.local_day import ( query_local_day_summary, record_local_activity_interval, ) from fenris.status import read_status from fenris.store import init_store SOURCE_SCHEMA_VERSION = 5 LOCAL_DATE = "2026-09-02" TIMEZONE = "Asia/Kolkata" UTC_START = "2026-09-01T18:30:00+00:00" UTC_END = "2026-09-02T18:30:00+00:00" def _legacy_store(path: Path) -> sqlite3.Connection: """Build a v5 store with one old coarse local-day summary.""" conn = init_store(path) conn.execute( "INSERT INTO local_days " "(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, " " bytes_read, coverage, sample_count, complete, activity_incomplete, " " activity_precision) " "VALUES (?, ?, '+05:30', ?, ?, 999999, 888888, 1.0, 24, 1, 0, 'legacy')", (LOCAL_DATE, TIMEZONE, UTC_START, UTC_END), ) conn.execute( "INSERT INTO monitoring_periods (started_at, ended_at) VALUES (?, ?)", (UTC_START, "2026-09-03T00:00:00+00:00"), ) conn.execute( "INSERT INTO controller_segments " "(opened_at, identity_key, identity_degraded) VALUES (?, ?, 0)", (UTC_START, "controller-a"), ) conn.execute( "INSERT INTO hour_observations " "(hour, bytes_written_delta, bytes_read_delta, sample_count, coverage) " "VALUES ('2026-09-02T00:00:00+00:00', 123, 45, 2, 1.0)" ) conn.execute( "INSERT INTO day_aggregates " "(day, bytes_written_delta, bytes_read_delta, coverage) " "VALUES ('2026-09-02', 678, 90, 1.0)" ) conn.execute( "INSERT INTO samples (ts, device, bytes_written, bytes_read, power_on_hours, " " segment_id, local_tz) VALUES (?, '/dev/nvme0', 1000, 2000, 100, 1, ?)", ("2026-09-01T19:00:00+00:00", TIMEZONE), ) conn.execute( "INSERT INTO samples (ts, device, bytes_written, bytes_read, power_on_hours, " " segment_id, local_tz) VALUES (?, '/dev/nvme0', 11000, 5000, 115, 1, ?)", ("2026-09-02T10:00:00+00:00", TIMEZONE), ) conn.execute(f"PRAGMA user_version={SOURCE_SCHEMA_VERSION}") conn.commit() return conn def test_upgrade_rebuilds_legacy_day_from_samples_and_keeps_recorded_bounds( tmp_path, monkeypatch, ): db = tmp_path / "legacy.db" conn = _legacy_store(db) conn.close() monkeypatch.setenv("TZ", "UTC") conn = init_store(db) conn.close() with read_status( db, datetime(2026, 9, 4, tzinfo=timezone.utc), query_services=False, ) as (reader, composition): assert reader is not None assert composition.store_fault is None summary = query_local_day_summary( reader, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary is not None assert summary["local_date"] == LOCAL_DATE assert summary["tz_name"] == TIMEZONE assert summary["tz_offset"] == "+05:30" assert summary["utc_start"] == UTC_START assert summary["utc_end"] == UTC_END assert summary["bytes_written"] == 10_000 assert summary["bytes_read"] == 3_000 assert summary["activity_precision"] == "measured" assert summary["segments"] == [{ "segment_id": 1, "bytes_written": 10_000, "bytes_read": 3_000, "activity_seconds": 54_000, "activity_intervals": 1, }] assert summary["activity_state"] == "incomplete" assert reader.execute( "SELECT bytes_written_delta FROM hour_observations" ).fetchone()[0] == 123 assert reader.execute( "SELECT bytes_written_delta FROM day_aggregates" ).fetchone()[0] == 678 conn = init_store(db) conn.close() with read_status( db, datetime(2026, 9, 4, tzinfo=timezone.utc), query_services=False, ) as (reader, _composition): assert reader is not None summary = query_local_day_summary( reader, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] == 10_000 assert summary["bytes_read"] == 3_000 def test_repair_rebuilds_legacy_local_day_once_across_restarts(tmp_path): from fenris.repair import repair_derivation db = tmp_path / "repair.db" conn = _legacy_store(db) first = repair_derivation(conn) assert first.ok summary = query_local_day_summary( conn, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] == 10_000 assert summary["bytes_read"] == 3_000 conn.close() conn = sqlite3.connect(db) second = repair_derivation(conn) assert second.ok summary = query_local_day_summary( conn, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] == 10_000 assert summary["bytes_read"] == 3_000 assert conn.execute( "SELECT bytes_written, bytes_read, activity_intervals " "FROM local_day_segment_totals" ).fetchall() == [(10_000, 3_000, 1)] conn.close() def test_interrupted_repair_keeps_local_day_rebuild_retryable(tmp_path): from fenris.repair import repair_derivation db = tmp_path / "repair-interrupted.db" conn = _legacy_store(db) conn.execute( "CREATE TRIGGER interrupt_repair_local_rebuild " "BEFORE INSERT ON local_day_segment_totals " "BEGIN SELECT RAISE(ABORT, 'simulated repair interruption'); END" ) conn.commit() failed = repair_derivation(conn) assert not failed.ok assert query_local_day_summary( conn, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), )["bytes_written"] is None assert conn.execute( "SELECT activity_precision, bytes_written FROM local_days" ).fetchone() == ("legacy", 999_999) conn.execute("DROP TRIGGER interrupt_repair_local_rebuild") conn.commit() conn.close() conn = sqlite3.connect(db) retried = repair_derivation(conn) assert retried.ok summary = query_local_day_summary( conn, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] == 10_000 assert summary["bytes_read"] == 3_000 conn.close() def test_stale_repair_marker_does_not_block_crash_retry(tmp_path): from fenris.repair import repair_derivation conn = _legacy_store(tmp_path / "stale-repair.db") conn.execute( "INSERT OR REPLACE INTO store_metadata (key, value) " "VALUES ('repair_in_progress', 'true')" ) conn.commit() result = repair_derivation(conn) assert result.ok assert conn.execute( "SELECT value FROM store_metadata WHERE key = 'repair_in_progress'" ).fetchone() == ("false",) assert query_local_day_summary( conn, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), )["bytes_written"] == 10_000 conn.close() def test_upgrade_uses_whole_utc_hours_but_skips_midnight_straddling_hours( tmp_path, ): db = tmp_path / "coarse.db" conn = _legacy_store(db) conn.execute("DELETE FROM samples") conn.execute( "INSERT INTO hour_observations " "(hour, bytes_written_delta, bytes_read_delta, sample_count, " " active_seconds, coverage) " "VALUES ('2026-09-01T18:00:00+00:00', 99999, 9999, 2, 3600, 1.0)" ) conn.execute( "INSERT INTO hour_observations " "(hour, bytes_written_delta, bytes_read_delta, sample_count, " " active_seconds, coverage) " "VALUES ('2026-09-02T18:00:00+00:00', 88888, 8888, 2, 3600, 1.0)" ) conn.commit() conn.close() conn = init_store(db) summary = query_local_day_summary( conn, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] == 123 assert summary["bytes_read"] == 45 assert summary["activity_precision"] == "coarse" assert summary["activity_state"] == "incomplete" assert summary["utc_start"] == UTC_START assert summary["utc_end"] == UTC_END conn.execute( "INSERT INTO samples " "(id, ts, device, bytes_written, bytes_read, segment_id, local_tz) " "VALUES (10, '2026-09-02T12:00:00+00:00', '/dev/nvme0', 1000, 2000, 1, ?), " " (11, '2026-09-02T12:05:00+00:00', '/dev/nvme0', 2000, 2500, 1, ?)", (TIMEZONE, TIMEZONE), ) conn.commit() assert record_local_activity_interval( conn, { "id": 10, "ts": "2026-09-02T12:00:00+00:00", "bytes_written": 1_000, "bytes_read": 2_000, "local_tz": TIMEZONE, }, { "id": 11, "ts": "2026-09-02T12:05:00+00:00", "bytes_written": 2_000, "bytes_read": 2_500, "local_tz": TIMEZONE, }, start_sample_id=10, end_sample_id=11, segment_id=1, ) == "known" summary = query_local_day_summary( conn, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] == 1_000 assert summary["bytes_read"] == 500 assert summary["activity_precision"] == "measured" assert summary["segments"] == [{ "segment_id": 1, "bytes_written": 1_000, "bytes_read": 500, "activity_seconds": 300, "activity_intervals": 1, }] conn.close() def test_upgrade_does_not_double_count_coarse_hours_in_overlapping_days(tmp_path): db = tmp_path / "overlapping-coarse-days.db" conn = _legacy_store(db) conn.execute("DELETE FROM samples") conn.execute( "INSERT INTO local_days " "(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, " " bytes_read, coverage, sample_count, complete, activity_precision) " "VALUES ('2026-09-02', 'UTC', '+00:00', " "'2026-09-02T00:00:00+00:00', '2026-09-03T00:00:00+00:00', " "999999, 888888, 1.0, 24, 1, 'legacy')" ) conn.commit() conn.close() conn = init_store(db) for tz_name in (TIMEZONE, "UTC"): summary = query_local_day_summary( conn, LOCAL_DATE, tz_name, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] is None assert summary["bytes_read"] is None assert summary["activity_precision"] == "unavailable" assert summary["activity_state"] == "unavailable" assert conn.execute( "SELECT bytes_written_delta, bytes_read_delta FROM hour_observations " "WHERE hour = '2026-09-02T00:00:00+00:00'" ).fetchone() == (123, 45) conn.close() def test_upgrade_retains_local_midnight_interval_once_as_shared_evidence(tmp_path): db = tmp_path / "shared.db" conn = _legacy_store(db) conn.execute( "INSERT INTO local_days " "(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, " " bytes_read, coverage, sample_count, complete, activity_precision) " "VALUES ('2026-09-01', ?, '+05:30', " "'2026-08-31T18:30:00+00:00', '2026-09-01T18:30:00+00:00', " "500, 600, 1.0, 24, 1, 'legacy')", (TIMEZONE,), ) conn.execute( "UPDATE monitoring_periods SET started_at = '2026-08-31T18:00:00+00:00'" ) conn.execute( "UPDATE controller_segments SET opened_at = '2026-08-31T18:00:00+00:00'" ) conn.execute( "UPDATE samples SET ts = '2026-09-01T18:25:00+00:00', " "bytes_written = 1000, bytes_read = 2000 WHERE id = 1" ) conn.execute( "UPDATE samples SET ts = '2026-09-01T18:35:00+00:00', " "bytes_written = 1100, bytes_read = 2050 WHERE id = 2" ) conn.commit() conn.close() conn = init_store(db) shared = conn.execute( "SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) " "FROM local_day_unallocated_evidence" ).fetchone() assert shared == (1, 100, 50) for local_date in ("2026-09-01", "2026-09-02"): summary = query_local_day_summary( conn, local_date, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] is None assert summary["shared_bytes_written"] == 100 assert summary["shared_bytes_read"] == 50 assert summary["activity_state"] == "incomplete" from fenris.repair import repair_derivation assert repair_derivation(conn).ok assert conn.execute( "SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) " "FROM local_day_unallocated_evidence" ).fetchone() == (1, 100, 50) conn.close() def test_interrupted_upgrade_keeps_legacy_day_retryable(tmp_path): db = tmp_path / "interrupted.db" conn = _legacy_store(db) conn.execute( "CREATE TRIGGER interrupt_local_rebuild " "BEFORE INSERT ON local_day_segment_totals " "BEGIN SELECT RAISE(ABORT, 'simulated interruption'); END" ) conn.commit() conn.close() with pytest.raises(sqlite3.IntegrityError, match="simulated interruption"): init_store(db) conn = sqlite3.connect(db) assert conn.execute("PRAGMA user_version").fetchone() == (SOURCE_SCHEMA_VERSION,) assert conn.execute( "SELECT activity_precision, bytes_written FROM local_days" ).fetchone() == ("legacy", 999_999) conn.execute("DROP TRIGGER interrupt_local_rebuild") conn.commit() conn.close() conn = init_store(db) summary = query_local_day_summary( conn, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] == 10_000 assert summary["bytes_read"] == 3_000 conn.close() def test_prior_migration_step_commits_before_local_day_repair(tmp_path): db = tmp_path / "migration-steps.db" conn = _legacy_store(db) conn.execute("PRAGMA user_version=4") conn.execute( "CREATE TRIGGER interrupt_local_rebuild BEFORE UPDATE OF bytes_written " "ON local_days BEGIN SELECT RAISE(ABORT, 'simulated interruption'); END" ) conn.commit() conn.close() with pytest.raises(sqlite3.IntegrityError, match="simulated interruption"): init_store(db) conn = sqlite3.connect(db) assert conn.execute("PRAGMA user_version").fetchone() == (5,) assert conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' " "AND name='local_day_segment_totals'" ).fetchone() is not None assert conn.execute( "SELECT bytes_written FROM local_days WHERE local_date=?", (LOCAL_DATE,), ).fetchone() == (999_999,) conn.close() def test_upgrade_does_not_allocate_unzoned_interval_to_overlapping_timezones( tmp_path, ): db = tmp_path / "overlapping-timezones.db" conn = _legacy_store(db) conn.execute( "INSERT INTO local_days " "(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, " " bytes_read, coverage, sample_count, complete, activity_precision) " "VALUES ('2026-09-02', 'UTC', '+00:00', " "'2026-09-02T00:00:00+00:00', '2026-09-03T00:00:00+00:00', " "999999, 888888, 1.0, 24, 1, 'legacy')" ) conn.execute( "UPDATE samples SET ts = '2026-09-02T01:00:00+00:00', local_tz = NULL " "WHERE id = 1" ) conn.execute( "UPDATE samples SET ts = '2026-09-02T10:00:00+00:00', local_tz = NULL " "WHERE id = 2" ) conn.commit() conn.close() conn = init_store(db) for tz_name in (TIMEZONE, "UTC"): summary = query_local_day_summary( conn, LOCAL_DATE, tz_name, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] is None assert summary["bytes_read"] is None assert summary["activity_precision"] == "unavailable" assert summary["activity_state"] == "unavailable" conn.close() def test_upgrade_rejects_interval_before_recorded_controller_segment( tmp_path, ): db = tmp_path / "segment-boundary.db" conn = _legacy_store(db) conn.execute( "UPDATE controller_segments " "SET opened_at = '2026-09-01T20:00:00+00:00' WHERE id = 1" ) conn.commit() conn.close() conn = init_store(db) summary = query_local_day_summary( conn, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] is None assert summary["bytes_read"] is None assert summary["activity_precision"] == "unavailable" conn.close() def test_sample_at_local_midnight_does_not_hide_coarse_reconstruction(tmp_path): db = tmp_path / "midnight-sample.db" conn = _legacy_store(db) conn.execute( "UPDATE samples SET ts = '2026-09-01T17:00:00+00:00' WHERE id = 1" ) conn.execute( "UPDATE samples SET ts = ?, local_tz = ? WHERE id = 2", (UTC_END, TIMEZONE), ) conn.commit() conn.close() conn = init_store(db) summary = query_local_day_summary( conn, LOCAL_DATE, TIMEZONE, datetime(2026, 9, 4, tzinfo=timezone.utc), ) assert summary["bytes_written"] == 123 assert summary["bytes_read"] == 45 assert summary["activity_precision"] == "coarse" assert summary["activity_state"] == "incomplete" conn.close()