fix(recovery): repair out-of-window timestamp cells in the recovered database
This commit is contained in:
@@ -16,6 +16,7 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterator, Optional
|
||||
|
||||
from hermes_cli.timefmt import EPOCH_MAX, EPOCH_MIN
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_common import FTS_STORAGE_VERSION, SCHEMA_VERSION
|
||||
from hermes_state_repair import _db_opens_cleanly
|
||||
@@ -941,9 +942,47 @@ def _sanitize_session_model_config(destination: sqlite3.Connection) -> int:
|
||||
)
|
||||
|
||||
|
||||
def _repair_out_of_window_timestamps(destination: sqlite3.Connection) -> int:
|
||||
"""Rewrite ``messages``/``sessions`` timestamp cells outside the ``coerce_epoch`` window; returns the count.
|
||||
|
||||
A damaged cell can decode as a valid-looking garbage double (``5.49e+246``) that the salvage copies
|
||||
verbatim, and one such row pins the session's recency and breaks every renderer that turns it into
|
||||
a date (#91536). A message takes its nearest valid neighbour's time in the same session (keeps the
|
||||
order), then its session's start, then now; ``started_at`` takes the earliest valid message; the
|
||||
nullable ``ended_at``/``last_activity_at`` become NULL.
|
||||
"""
|
||||
message_columns, session_columns = _table_columns(destination, "messages"), _table_columns(destination, "sessions")
|
||||
ok = f"BETWEEN {EPOCH_MIN!r} AND {EPOCH_MAX!r}"
|
||||
now = "CAST(strftime('%s', 'now') AS REAL)"
|
||||
repaired = 0
|
||||
with _immediate_transaction(destination):
|
||||
if "timestamp" in message_columns:
|
||||
repaired += _reconcile(
|
||||
destination, "messages", f"NOT (timestamp {ok})",
|
||||
"UPDATE messages SET timestamp = COALESCE("
|
||||
f"(SELECT p.timestamp FROM messages p WHERE p.session_id = messages.session_id AND p.id < messages.id"
|
||||
f" AND p.timestamp {ok} ORDER BY p.id DESC LIMIT 1),"
|
||||
f"(SELECT n.timestamp FROM messages n WHERE n.session_id = messages.session_id AND n.id > messages.id"
|
||||
f" AND n.timestamp {ok} ORDER BY n.id LIMIT 1),"
|
||||
f"(SELECT s.started_at FROM sessions s WHERE s.id = messages.session_id AND s.started_at {ok}), {now})",
|
||||
)
|
||||
if "started_at" in session_columns:
|
||||
repaired += _reconcile(
|
||||
destination, "sessions", f"NOT (started_at {ok})",
|
||||
"UPDATE sessions SET started_at = COALESCE((SELECT MIN(m.timestamp) FROM messages m"
|
||||
f" WHERE m.session_id = sessions.id AND m.timestamp {ok}), {now})",
|
||||
)
|
||||
for column in ("ended_at", "last_activity_at"):
|
||||
if column in session_columns:
|
||||
repaired += _reconcile(
|
||||
destination, "sessions", f"NOT ({column} {ok})", f"UPDATE sessions SET {column} = NULL")
|
||||
return repaired
|
||||
|
||||
|
||||
def _finalize_derived_metadata(destination: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Sanitize copied JSON columns and stamp metadata the new destination actually owns."""
|
||||
"""Sanitize copied JSON columns and timestamps, and stamp metadata the new destination actually owns."""
|
||||
model_config_reset = _sanitize_session_model_config(destination)
|
||||
timestamps_repaired = _repair_out_of_window_timestamps(destination)
|
||||
fts_tables = {
|
||||
str(row[0])
|
||||
for row in destination.execute(
|
||||
@@ -951,7 +990,8 @@ def _finalize_derived_metadata(destination: sqlite3.Connection) -> dict[str, Any
|
||||
).fetchall()
|
||||
}
|
||||
result: dict[str, Any] = {
|
||||
"fts_tables": sorted(fts_tables), "finalized": False, "model_config_reset": model_config_reset}
|
||||
"fts_tables": sorted(fts_tables), "finalized": False, "model_config_reset": model_config_reset,
|
||||
"timestamps_repaired": timestamps_repaired}
|
||||
if fts_tables != {"messages_fts", "messages_fts_trigram"}:
|
||||
result["error"] = "fresh destination is missing required FTS tables"
|
||||
return result
|
||||
|
||||
55
tests/hermes_cli/test_session_recovery_timestamps.py
Normal file
55
tests/hermes_cli/test_session_recovery_timestamps.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Invariant: session recovery never ships an out-of-window timestamp cell (#91536).
|
||||
|
||||
A damaged page can decode as a valid-looking garbage double (``5.49e+246``, ``1e-310`` magnitudes
|
||||
are uninitialised-memory patterns) that the copy writes verbatim. Both recovery lanes pass through
|
||||
``_finalize_derived_metadata``, so the repair lives there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.session_recovery import recover_session_database
|
||||
from hermes_cli.timefmt import coerce_epoch
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
def _damaged_source(path: Path) -> list[float]:
|
||||
db = SessionDB(db_path=path)
|
||||
try:
|
||||
db.create_session("s", "cli")
|
||||
for text in ("one", "two", "three"):
|
||||
db.append_message("s", "user", text)
|
||||
stamps = [row["timestamp"] for row in db.get_messages("s")]
|
||||
finally:
|
||||
db.close()
|
||||
raw = sqlite3.connect(str(path))
|
||||
try:
|
||||
middle = raw.execute("SELECT id FROM messages WHERE session_id = 's' ORDER BY id LIMIT 1 OFFSET 1").fetchone()[0]
|
||||
raw.execute("UPDATE messages SET timestamp = 5.4905047707024164e+246 WHERE id = ?", (middle,))
|
||||
raw.execute("UPDATE sessions SET started_at = 'garbage', ended_at = 1e300, last_activity_at = -5 WHERE id = 's'")
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
return stamps
|
||||
|
||||
|
||||
def test_recovery_repairs_out_of_window_timestamps(tmp_path):
|
||||
source, output = tmp_path / "state.db", tmp_path / "recovered.db"
|
||||
stamps = _damaged_source(source)
|
||||
|
||||
report = recover_session_database(source, output)
|
||||
|
||||
assert report["verification"]["healthy"], report["verification"]["errors"]
|
||||
conn = sqlite3.connect(str(output))
|
||||
try:
|
||||
messages = [r[0] for r in conn.execute("SELECT timestamp FROM messages WHERE session_id = 's' ORDER BY id")]
|
||||
started, ended, active = conn.execute(
|
||||
"SELECT started_at, ended_at, last_activity_at FROM sessions WHERE id = 's'").fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
# Every cell is a trusted epoch again, message order is preserved, and the good cells are untouched.
|
||||
assert all(coerce_epoch(ts) is not None for ts in messages)
|
||||
assert messages[0] == stamps[0] and messages[2] == stamps[2] and messages == sorted(messages)
|
||||
assert started == min(messages) and ended is None and active is None
|
||||
Reference in New Issue
Block a user