diff --git a/src/dfe_engine/appmgmt/operations.py b/src/dfe_engine/appmgmt/operations.py index 9a534f599..056bcca08 100644 --- a/src/dfe_engine/appmgmt/operations.py +++ b/src/dfe_engine/appmgmt/operations.py @@ -20,6 +20,7 @@ import re from collections.abc import Sequence from dataclasses import dataclass, field +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -363,9 +364,14 @@ def _last_increase(series: Sequence[tuple[float, float, float]]) -> float | None def _epoch(value: Any) -> float | None: - """Coerce a ClickHouse DateTime or numeric to a POSIX timestamp.""" + """Coerce a ClickHouse DateTime or numeric to a POSIX timestamp. + + clickhouse-connect returns naive datetimes in UTC, so a naive value is read as + UTC rather than the host's local time. + """ if value is None: return None - if hasattr(value, "timestamp"): - return float(value.timestamp()) + if isinstance(value, datetime): + aware = value if value.tzinfo is not None else value.replace(tzinfo=UTC) + return aware.timestamp() return float(value) diff --git a/tests/unit/test_appmgmt/test_operations.py b/tests/unit/test_appmgmt/test_operations.py index 99fc15d68..c14af5c73 100644 --- a/tests/unit/test_appmgmt/test_operations.py +++ b/tests/unit/test_appmgmt/test_operations.py @@ -13,6 +13,10 @@ latest time any series MOVED, not the last time the loader reported. """ +import time +from datetime import datetime +from zoneinfo import ZoneInfo + import pytest from dfe_engine.appmgmt.operations import ( @@ -221,6 +225,37 @@ def test_an_instance_with_telemetry_is_reporting(): assert status.last_seen_epoch == 1757000000.0 +@pytest.fixture +def sydney_local_time(monkeypatch: pytest.MonkeyPatch): + """Run with a host timezone ten or eleven hours off UTC.""" + monkeypatch.setenv("TZ", "Australia/Sydney") + time.tzset() + yield + monkeypatch.undo() + time.tzset() + + +def test_a_naive_clickhouse_datetime_is_read_as_utc_on_a_non_utc_host( + sydney_local_time, +): + # clickhouse-connect hands back naive datetimes that are UTC. + naive = datetime(2025, 9, 4, 15, 33, 20) + ch = PerQueryClickHouse({"AS last_seen": [(naive, 12)]}) + + status = OperationalReader(ch, "dfe").status("dfe-loader") + + assert status.last_seen_epoch == 1757000000.0 + + +def test_an_aware_datetime_keeps_its_own_zone(sydney_local_time): + aware = datetime(2025, 9, 5, 1, 33, 20, tzinfo=ZoneInfo("Australia/Sydney")) + ch = PerQueryClickHouse({"AS last_seen": [(aware, 12)]}) + + status = OperationalReader(ch, "dfe").status("dfe-loader") + + assert status.last_seen_epoch == 1757000000.0 + + def test_uptime_reads_the_start_time_every_dfe_app_emits(): # A scalo-py service reports start_time_seconds and no process_start_time_seconds. ch = PerQueryClickHouse(