fix: publish collection history atomically
This commit is contained in:
+47
-65
@@ -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()
|
||||
|
||||
@@ -96,6 +96,7 @@ def derive_hours_from_interval(
|
||||
"""Derive hour observations from a sample pair interval.
|
||||
|
||||
Returns list of hour observation dicts that were written/updated.
|
||||
Caller owns the transaction.
|
||||
"""
|
||||
prev_ts = _parse_ts(prev_sample["ts"])
|
||||
next_ts = _parse_ts(next_sample["ts"])
|
||||
@@ -207,9 +208,6 @@ def _upsert_hour_observation(
|
||||
"WHERE id = ?",
|
||||
(new_bw, new_br, new_samples, existing[0]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _add_unattributed_bytes(
|
||||
conn: sqlite3.Connection,
|
||||
prev_ts: datetime,
|
||||
@@ -241,4 +239,3 @@ def _add_unattributed_bytes(
|
||||
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
|
||||
(bw_delta, br_delta, day),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -69,6 +69,7 @@ def cmd_enable(args: argparse.Namespace) -> None:
|
||||
open_period = get_open_period(conn)
|
||||
if open_period is None:
|
||||
ensure_period_open(conn, now)
|
||||
conn.commit()
|
||||
print("Monitoring period opened at", now.isoformat())
|
||||
else:
|
||||
print("Monitoring period already open (id=%d)" % open_period["id"])
|
||||
|
||||
@@ -16,7 +16,7 @@ def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
|
||||
"""Ensure a monitoring period is open. If none exists, open one at run_time.
|
||||
|
||||
Spec §9.8: A collection run finding no open monitoring period opens one
|
||||
at the run moment, never backdated.
|
||||
at the run moment, never backdated. Caller owns the transaction.
|
||||
"""
|
||||
if get_open_period(conn) is not None:
|
||||
return # Already open — no-op
|
||||
@@ -26,7 +26,6 @@ def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
|
||||
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
|
||||
(ts,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def close_period(
|
||||
|
||||
@@ -25,11 +25,14 @@ def detect_system_tz() -> str:
|
||||
|
||||
localtime = Path("/etc/localtime")
|
||||
if localtime.is_symlink():
|
||||
target = os.readlink(str(localtime))
|
||||
# Strip common prefixes: /usr/share/zoneinfo/, /usr/lib/zoneinfo/
|
||||
for prefix in ("/usr/share/zoneinfo/", "/usr/lib/zoneinfo/"):
|
||||
if target.startswith(prefix):
|
||||
return target[len(prefix):]
|
||||
target_path = Path(os.readlink(str(localtime)))
|
||||
if not target_path.is_absolute():
|
||||
target_path = localtime.parent / target_path
|
||||
target = target_path.resolve().as_posix()
|
||||
marker = "/zoneinfo/"
|
||||
marker_index = target.find(marker)
|
||||
if marker_index >= 0:
|
||||
return target[marker_index + len(marker):]
|
||||
return target
|
||||
|
||||
return "UTC"
|
||||
|
||||
Reference in New Issue
Block a user