fix: publish collection history atomically

This commit is contained in:
xavierk
2026-09-28 01:56:32 +05:30
parent 4550dd5111
commit 437ea6be73
8 changed files with 251 additions and 76 deletions
+47 -65
View File
@@ -5,7 +5,7 @@ This module implements the thinnest complete write path:
- Acquire controller identity from sysfs
- Normalize identity exactly once at write time
- Validate every row against store invariants
- Commit one well-formed sample
- Publish the sample and derived evidence in one collection-owned transaction
No code path outside the collector interrogates the device.
"""
@@ -200,7 +200,7 @@ def write_sample(
"""Write one sample to the observation store.
Identity normalization happens exactly once here.
Returns segment info for the caller.
Returns segment info for the caller. Caller owns the transaction.
"""
from .segment import find_current_segment, should_open_new_segment, open_segment
@@ -262,8 +262,6 @@ def write_sample(
),
)
conn.commit()
return {
"segment_opened": segment_opened,
"segment_reason": reason,
@@ -284,6 +282,7 @@ def run_collection(
This is the main entry point for the collector.
Returns the run outcome.
"""
conn = None
try:
# Acquire counters and thermal evidence
counters = acquire_from_smartctl(smartctl_data)
@@ -309,72 +308,55 @@ def run_collection(
if history_path.exists():
import_legacy_history(conn, history_path, clock=clock)
try:
# Ensure monitoring period is open (issue #73 AC2)
ensure_period_open(conn, clock.utcnow())
# Validate invariants
validate_sample_invariants(sample, conn)
# Write sample and get segment info
seg_info = write_sample(sample, identity, conn, clock)
# Derive hour observations from interval with previous sample
try:
# Find the sample we just wrote
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1")
current_id = cursor.fetchone()[0]
# Collection owns one transaction for the sample and its evidence.
ensure_period_open(conn, clock.utcnow())
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
if prev is not None:
# Build current sample dict for derivation
current = {
"id": current_id,
"ts": sample["ts"],
"bytes_written": sample["bytes_written"],
"bytes_read": sample["bytes_read"],
"power_on_hours": sample["power_on_hours"],
"temperature_c": sample["temperature_c"],
"data_units_written": sample["data_units_written"],
"data_units_read": sample["data_units_read"],
}
derive_hours_from_interval(conn, prev, current)
validate_sample_invariants(sample, conn)
seg_info = write_sample(sample, identity, conn, clock)
# Rebuild day aggregates from hour observations
from .day_aggregate import derive_all_days, persist_day_aggregate
for agg in derive_all_days(conn):
persist_day_aggregate(conn, agg)
# Derive local-day summary using system timezone (issue #90)
try:
from .tz_util import detect_system_tz
from .local_day import derive_local_day_summary, persist_local_day
tz_name = detect_system_tz()
clock_now = clock.utcnow()
local_summary = derive_local_day_summary(conn, tz_name, clock_now)
if local_summary is not None:
persist_local_day(conn, local_summary)
except Exception:
# Local-day derivation failure must not prevent publication
pass
conn.commit()
except Exception:
# Derivation failure must not prevent sample persistence (issue #73 AC6)
pass
return {
"ok": True,
"sample_count": 1,
"store_path": str(store_path),
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1")
current_id = cursor.fetchone()[0]
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
if prev is not None:
current = {
"id": current_id,
"ts": sample["ts"],
"bytes_written": sample["bytes_written"],
"bytes_read": sample["bytes_read"],
"power_on_hours": sample["power_on_hours"],
"temperature_c": sample["temperature_c"],
"data_units_written": sample["data_units_written"],
"data_units_read": sample["data_units_read"],
}
finally:
conn.close()
except (AcquisitionError, InvariantViolationError) as e:
derive_hours_from_interval(conn, prev, current)
from .day_aggregate import derive_all_days, persist_day_aggregate
for agg in derive_all_days(conn):
persist_day_aggregate(conn, agg)
from .tz_util import detect_system_tz
from .local_day import derive_local_day_summary, persist_local_day
tz_name = detect_system_tz()
local_summary = derive_local_day_summary(conn, tz_name, clock.utcnow())
if local_summary is not None:
persist_local_day(conn, local_summary)
conn.commit()
return {
"ok": True,
"sample_count": 1,
"store_path": str(store_path),
}
except Exception as e:
if conn is not None:
conn.rollback()
return {
"ok": False,
"error": str(e),
"error_type": type(e).__name__,
}
finally:
if conn is not None:
conn.close()