Skip to content

Commit 14848e9

Browse files
authored
Merge pull request #815 from ZacLou/issue-727-ingestion-throughput-metrics
feat(ingestion): per-batch progress and throughput metrics (#727)
2 parents 7ede0d1 + 8f3ceae commit 14848e9

5 files changed

Lines changed: 255 additions & 4 deletions

File tree

astroml/ingestion/batch_metrics.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Per-batch ingestion progress/throughput metrics recorder (Issue #727)."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
from dataclasses import dataclass
7+
from typing import Any
8+
9+
from astroml.ingestion.metrics import (
10+
INGESTION_BATCH_DURATION_SECONDS,
11+
INGESTION_BATCH_LEDGERS,
12+
INGESTION_BATCH_THROUGHPUT,
13+
)
14+
15+
16+
@dataclass
17+
class BatchCounters:
18+
"""Mutable counters for a single batch window."""
19+
20+
processed: int = 0
21+
skipped: int = 0
22+
errors: int = 0
23+
24+
def observe(self, outcome: Any) -> None:
25+
"""Record one ledger outcome.
26+
27+
Args:
28+
outcome: An object with a ``status`` attribute of either
29+
``"processed"``, ``"skipped"``, or ``"error"``.
30+
"""
31+
status = getattr(outcome, "status", "unknown")
32+
if status == "processed":
33+
self.processed += 1
34+
elif status == "skipped":
35+
self.skipped += 1
36+
elif status == "error":
37+
self.errors += 1
38+
39+
40+
class BatchMetricsRecorder:
41+
"""Records per-batch ingestion metrics.
42+
43+
Call :meth:`start` at the beginning of a batch, :meth:`observe` for each
44+
ledger outcome, and :meth:`finish` when the batch ends to publish Prometheus
45+
metrics.
46+
"""
47+
48+
def __init__(self) -> None:
49+
self._counters = BatchCounters()
50+
self._start_time: float = 0.0
51+
52+
def start(self) -> None:
53+
"""Reset counters and start the batch timer."""
54+
self._counters = BatchCounters()
55+
self._start_time = time.perf_counter()
56+
57+
def observe(self, outcome: Any) -> None:
58+
"""Record one ledger outcome in the current batch."""
59+
self._counters.observe(outcome)
60+
61+
def finish(self) -> None:
62+
"""Publish metrics for the current batch."""
63+
elapsed = time.perf_counter() - self._start_time
64+
INGESTION_BATCH_DURATION_SECONDS.observe(elapsed)
65+
INGESTION_BATCH_LEDGERS.labels(status="processed").inc(self._counters.processed)
66+
INGESTION_BATCH_LEDGERS.labels(status="skipped").inc(self._counters.skipped)
67+
INGESTION_BATCH_LEDGERS.labels(status="error").inc(self._counters.errors)
68+
total = self._counters.processed + self._counters.skipped + self._counters.errors
69+
INGESTION_BATCH_THROUGHPUT.set(total / elapsed if elapsed > 0 else 0.0)

astroml/ingestion/metrics.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,20 @@
6161
"astroml_ingestion_batch_flush_seconds",
6262
"Time spent flushing a batch of models",
6363
)
64+
65+
# Per-batch ingestion progress / throughput metrics (Issue #727)
66+
INGESTION_BATCH_DURATION_SECONDS = Histogram(
67+
"astroml_ingestion_batch_duration_seconds",
68+
"Wall-clock time spent processing one batch of ledgers",
69+
)
70+
71+
INGESTION_BATCH_LEDGERS = Counter(
72+
"astroml_ingestion_batch_ledgers_total",
73+
"Total number of ledgers handled in batch metrics",
74+
["status"],
75+
)
76+
77+
INGESTION_BATCH_THROUGHPUT = Gauge(
78+
"astroml_ingestion_batch_throughput_ledgers_per_second",
79+
"Ledgers processed per second during the most recent batch",
80+
)

astroml/ingestion/service.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from astroml.core.abstracts import Ingestor
2626
from astroml.utils.validators import validate_positive_int, validate_range
2727

28+
from .batch_metrics import BatchMetricsRecorder
2829
from .state import StateStore
2930

3031
logger = logging.getLogger("astroml.ingestion.service")
@@ -221,17 +222,25 @@ def ingest_stream(
221222
from astroml.observability.metrics import track_active_job
222223

223224
pending_flush = 0
225+
batch_metrics = BatchMetricsRecorder()
226+
batch_metrics.start()
224227
try:
225228
# Active ingestion jobs gauge (issue #567). Entering the context
226229
# here keeps the gauge balanced even if the caller abandons the
227230
# generator partway through — GeneratorExit unwinds this `with`.
228231
with track_active_job("ingestion"):
229232
for offset, ledger_id in enumerate(range(start_ledger, end_ledger + 1), start=1):
230233
if ledger_id in processed_set:
231-
yield ledger_id, LedgerOutcome(ledger_id=ledger_id, status="skipped")
234+
outcome = LedgerOutcome(ledger_id=ledger_id, status="skipped")
232235
else:
233-
payload = fetch(ledger_id)
234-
process(ledger_id, payload)
236+
try:
237+
payload = fetch(ledger_id)
238+
process(ledger_id, payload)
239+
except Exception as exc:
240+
batch_metrics.observe(LedgerOutcome(ledger_id=ledger_id, status="error"))
241+
batch_metrics.finish()
242+
logger.error("Ingestion error for ledger %d: %s", ledger_id, exc)
243+
raise
235244
processed_set.add(ledger_id)
236245
state.last_processed_ledger = (
237246
ledger_id
@@ -242,9 +251,14 @@ def ingest_stream(
242251
if pending_flush >= batch_size:
243252
self.state.save(state)
244253
pending_flush = 0
245-
yield ledger_id, LedgerOutcome(ledger_id=ledger_id, status="processed")
254+
outcome = LedgerOutcome(ledger_id=ledger_id, status="processed")
255+
256+
batch_metrics.observe(outcome)
257+
yield ledger_id, outcome
246258

247259
if offset % batch_size == 0:
260+
batch_metrics.finish()
261+
batch_metrics.start()
248262
logger.info(
249263
"ingest_stream progress: %d/%d ledgers (up to %d)",
250264
offset,
@@ -254,6 +268,7 @@ def ingest_stream(
254268
finally:
255269
if pending_flush:
256270
self.state.save(state)
271+
batch_metrics.finish()
257272

258273
def ingest_incremental(
259274
self,

docs/ingestion-monitoring.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Ingestion monitoring
2+
3+
The ingestion pipeline exposes metrics and health checks so operators can
4+
monitor backfills and detect stalls.
5+
6+
## Heartbeat check
7+
8+
`astroml.observability.ingestion.check_ingestion_heartbeat` compares the
9+
current time to the `last_processed_at` timestamp recorded in the ingestion
10+
state store and returns a `CheckResult`:
11+
12+
- `OK` — a ledger was processed within `stale_threshold_seconds`.
13+
- `DEGRADED` — no ledger for `stale_threshold_seconds` (default 300s).
14+
- `FAIL` — no ledger for `fail_threshold_seconds` (default 2 × stale threshold).
15+
16+
## Per-batch throughput metrics
17+
18+
`astroml.ingestion.batch_metrics.BatchMetricsRecorder` emits progress metrics
19+
for each batch of ledgers handled by `IngestionService.ingest_stream`:
20+
21+
- `astroml_ingestion_batch_duration_seconds` — wall-clock time per batch.
22+
- `astroml_ingestion_batch_ledgers_total{status="processed|skipped|error"}`
23+
ledgers handled per batch.
24+
- `astroml_ingestion_batch_throughput_ledgers_per_second` — throughput of the
25+
most recent batch.
26+
27+
Use these metrics to monitor and tune long backfills, for example:
28+
29+
```promql
30+
rate(astroml_ingestion_batch_ledgers_total{status="processed"}[5m])
31+
```
32+
33+
## State store timestamp
34+
35+
`StateStore.mark_processed` records `last_processed_at` as an ISO-8601 UTC
36+
timestamp whenever a ledger is processed. Existing state files without the
37+
field are treated as having no recorded ingestion time and report
38+
`DEGRADED` until the next successful ingestion.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Tests for per-batch ingestion progress/throughput metrics (Issue #727)."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
from unittest.mock import MagicMock
7+
8+
import pytest
9+
from prometheus_client import REGISTRY
10+
11+
12+
@pytest.fixture(autouse=True)
13+
def _reset_batch_metrics():
14+
"""Reset batch metric counters before each test so tests are order-independent."""
15+
INGESTION_BATCH_LEDGERS.labels(status="processed")._value.set(0.0)
16+
INGESTION_BATCH_LEDGERS.labels(status="skipped")._value.set(0.0)
17+
INGESTION_BATCH_LEDGERS.labels(status="error")._value.set(0.0)
18+
INGESTION_BATCH_THROUGHPUT._value.set(0.0)
19+
INGESTION_BATCH_DURATION_SECONDS._sum.set(0.0)
20+
21+
from astroml.ingestion.batch_metrics import BatchMetricsRecorder
22+
from astroml.ingestion.metrics import (
23+
INGESTION_BATCH_DURATION_SECONDS,
24+
INGESTION_BATCH_LEDGERS,
25+
INGESTION_BATCH_THROUGHPUT,
26+
)
27+
28+
29+
class _Outcome:
30+
def __init__(self, status: str) -> None:
31+
self.status = status
32+
33+
34+
class TestBatchMetricsRecorder:
35+
def test_records_processed_and_skipped(self) -> None:
36+
recorder = BatchMetricsRecorder()
37+
recorder.start()
38+
recorder.observe(_Outcome("processed"))
39+
recorder.observe(_Outcome("processed"))
40+
recorder.observe(_Outcome("skipped"))
41+
recorder.finish()
42+
43+
assert (
44+
INGESTION_BATCH_LEDGERS.labels(status="processed")._value.get() == 2.0
45+
)
46+
assert INGESTION_BATCH_LEDGERS.labels(status="skipped")._value.get() == 1.0
47+
48+
def test_records_error_outcome(self) -> None:
49+
recorder = BatchMetricsRecorder()
50+
recorder.start()
51+
recorder.observe(_Outcome("error"))
52+
recorder.finish()
53+
54+
assert INGESTION_BATCH_LEDGERS.labels(status="error")._value.get() == 1.0
55+
56+
def test_throughput_is_non_negative(self) -> None:
57+
recorder = BatchMetricsRecorder()
58+
recorder.start()
59+
recorder.observe(_Outcome("processed"))
60+
recorder.finish()
61+
62+
throughput = INGESTION_BATCH_THROUGHPUT._value.get()
63+
assert throughput >= 0.0
64+
65+
def test_duration_observed(self) -> None:
66+
recorder = BatchMetricsRecorder()
67+
recorder.start()
68+
time.sleep(0.01)
69+
recorder.finish()
70+
71+
# Histogram exposes samples through _sum and _count
72+
assert INGESTION_BATCH_DURATION_SECONDS._sum.get() >= 0.01
73+
74+
def test_start_resets_counters(self) -> None:
75+
recorder = BatchMetricsRecorder()
76+
recorder.start()
77+
recorder.observe(_Outcome("processed"))
78+
recorder.finish()
79+
80+
recorder.start()
81+
recorder.finish()
82+
83+
# The second batch has no new processed observations, so the counter
84+
# should still be 1.0 (counters are monotonic).
85+
assert INGESTION_BATCH_LEDGERS.labels(status="processed")._value.get() == 1.0
86+
87+
88+
class TestIngestionServiceBatchMetrics:
89+
def test_emits_batch_metrics_after_batch_boundary(self, tmp_path) -> None:
90+
from astroml.ingestion.service import IngestionService
91+
from astroml.ingestion.state import StateStore
92+
93+
state_path = tmp_path / "state.json"
94+
store = StateStore(str(state_path))
95+
service = IngestionService(store)
96+
97+
list(service.ingest_stream(start_ledger=1, end_ledger=5, batch_size=5))
98+
99+
assert INGESTION_BATCH_LEDGERS.labels(status="processed")._value.get() == 5.0
100+
assert INGESTION_BATCH_THROUGHPUT._value.get() >= 0.0
101+
102+
def test_emits_batch_metrics_for_partial_final_batch(self, tmp_path) -> None:
103+
from astroml.ingestion.service import IngestionService
104+
from astroml.ingestion.state import StateStore
105+
106+
state_path = tmp_path / "state.json"
107+
store = StateStore(str(state_path))
108+
service = IngestionService(store)
109+
110+
list(service.ingest_stream(start_ledger=1, end_ledger=3, batch_size=5))
111+
112+
assert INGESTION_BATCH_LEDGERS.labels(status="processed")._value.get() == 3.0

0 commit comments

Comments
 (0)