Skip to content

Commit 976cd74

Browse files
committed
postgres data-observability - address review feedback on cron schedule support
- Replace inaccurate epoch literal/banner in test_data_observability.py with a one-line "why" comment. - Drop unused fake_time parameter on _make_cron_check. - Remove narrative debugging comments and dead fake_clock/_next_run mutations in test_schedule_advances_after_run, test_lateness_metric_emitted_for_cron, and test_lateness_clamped_at_zero. - Generalize the stale "Update scheduling timestamp" comment in run_job to cover both last_execution and next_run; drop the redundant fall-through comment in _get_due_queries. - Tighten spec.yaml descriptions for interval_seconds/schedule to match runtime behavior (skipped with warning if neither is set) instead of over-promising "at least one must be set". - Log a warning when cron_startup_lookback_seconds is negative instead of silently clamping to 0. - Add three regression tests: default-is-300, negative-clamped-with-warning, no-double-fire after a startup recovery. Signed-off-by: mobuchowski <maciej.obuchowski@datadoghq.com>
1 parent fbd84ed commit 976cd74

3 files changed

Lines changed: 62 additions & 63 deletions

File tree

postgres/assets/configuration/spec.yaml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -653,15 +653,16 @@ files:
653653
type: string
654654
- name: interval_seconds
655655
description: |
656-
How often (in seconds) to run this query. At least one of interval_seconds or
657-
schedule must be set. When both are present, schedule takes precedence and
658-
interval_seconds is ignored.
656+
How often (in seconds) to run this query. When both schedule and
657+
interval_seconds are present, schedule takes precedence and interval_seconds
658+
is ignored. If neither is set, the query is skipped at runtime with a warning.
659659
type: integer
660660
- name: schedule
661661
description: |
662662
A standard 5-field cron expression (minute hour dom month dow) specifying
663663
when to run this query. When set, takes precedence over interval_seconds.
664-
At least one of schedule or interval_seconds must be set.
664+
If neither schedule nor interval_seconds is set, the query is skipped at
665+
runtime with a warning.
665666
type: string
666667
- name: entity
667668
type: object

postgres/datadog_checks/postgres/data_observability.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,13 @@ def _cron_startup_lookback_seconds(self) -> int:
7373
configured = self._do_config.cron_startup_lookback_seconds
7474
if configured is None:
7575
return DEFAULT_CRON_STARTUP_LOOKBACK_SECONDS
76-
return max(0, configured)
76+
if configured < 0:
77+
self._log.warning(
78+
"Invalid cron_startup_lookback_seconds=%d: must be >= 0. Cron startup recovery is disabled.",
79+
configured,
80+
)
81+
return 0
82+
return configured
7783

7884
def _get_due_queries(self) -> list[tuple[Query, float]]:
7985
queries = self._do_config.queries or ()
@@ -99,7 +105,6 @@ def _get_due_queries(self) -> list[tuple[Query, float]]:
99105
# First fire (no prior last_run): treat scheduled time as `now` → lateness = 0.
100106
scheduled = (last_run + q.interval_seconds) if last_run > 0 else now
101107
due.append((q, scheduled))
102-
# mode is None → invalid query, already warned, skip
103108
return due
104109

105110
def _build_base_tags(self) -> list[str]:
@@ -201,9 +206,9 @@ def run_job(self):
201206
with self._check.db_pool.get_connection(q.dbname) as conn:
202207
result = self._execute_single_query(conn, q)
203208

204-
# Update scheduling timestamp immediately after execution, before
205-
# metric/event emission, so a serialization failure in the event
206-
# path cannot cause infinite re-execution of the same query.
209+
# Update scheduling state (last_execution and, for cron, next_run) before
210+
# metric/event emission, so a serialization failure in the emit path
211+
# cannot cause infinite re-execution of the same query.
207212
now_at_fire_end = time.time()
208213
self._last_execution[q.monitor_id] = now_at_fire_end
209214
if q.schedule:

postgres/tests/test_data_observability.py

Lines changed: 47 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,8 @@
1616
from datadog_checks.postgres import PostgreSql
1717
from datadog_checks.postgres.data_observability import EVENT_TRACK_TYPE
1818

19-
# ---------------------------------------------------------------------------
20-
# Cron schedule / lateness helpers
21-
# ---------------------------------------------------------------------------
22-
# A "frozen" epoch that sits between two hourly boundaries:
23-
# xx:49:00 (previous hour + 49 min)
24-
# xx:50:00 (next cron tick for "50 * * * *")
25-
# xx:51:00 (tick for "51 * * * *")
26-
#
27-
# We use an absolute epoch so croniter's calendar arithmetic stays stable.
28-
# 2026-01-01 00:49:00 UTC → 1751331940 (approx; exact value computed below)
29-
_BASE_EPOCH = calendar.timegm(datetime.datetime(2026, 1, 1, 0, 49, 0).timetuple()) # 00:49:00 UTC
19+
# Fixed epoch (2026-01-01 00:49:00 UTC) chosen to sit 1 minute before the ":50" cron tick used in tests.
20+
_BASE_EPOCH = calendar.timegm(datetime.datetime(2026, 1, 1, 0, 49, 0).timetuple())
3021

3122
pytestmark = pytest.mark.unit
3223

@@ -531,12 +522,11 @@ def test_multi_query_different_dbnames(aggregator, pg_instance):
531522
}
532523

533524

534-
def _make_cron_check(pg_instance, queries=None, fake_time=None):
535-
"""Create a check with a cron-scheduled query, optionally monkeypatching time.time."""
525+
def _make_cron_check(pg_instance, queries=None):
526+
"""Create a check with a cron-scheduled query."""
536527
if queries is None:
537528
queries = [deepcopy(CRON_QUERY)]
538-
check = _create_check(pg_instance, queries=queries)
539-
return check
529+
return _create_check(pg_instance, queries=queries)
540530

541531

542532
def test_schedule_query_does_not_fire_before_tick(pg_instance, monkeypatch):
@@ -582,19 +572,11 @@ def test_schedule_advances_after_run(pg_instance, monkeypatch):
582572
check = _make_cron_check(pg_instance)
583573
check.db_pool = _mock_db_pool(mock_conn)
584574

585-
# First run: registers first future tick (00:50:00 is already past, so next is 01:50:00)
575+
# First run at 00:50:05: registers next_run = 01:50:00 (croniter.get_next is strictly after now); no fire.
586576
check.data_observability.run_job()
587-
# At this point next_run should be set (but nothing fired yet — it's first sight)
588577
mid = CRON_QUERY['monitor_id']
589578
first_next_run = check.data_observability._next_run[mid]
590579

591-
# Second run: now we're past the registered next_run (which was set to 00:50:00)
592-
# Actually at 00:50:05 the first sight records 00:50:00 as next and skips. Let me re-check.
593-
# At 00:50:05 > 00:49:00 = _BASE_EPOCH, so first sight computes next_run = 00:50:00 (already past).
594-
# Wait — croniter(schedule, now).get_next() returns the first tick AFTER `now`.
595-
# At now=00:50:05, get_next("50 * * * *") = 01:50:00 (the next tick after 00:50:05).
596-
# So first call: next_run = 01:50:00 → skip (no fire).
597-
# Advance to 01:50:05:
598580
current_time[0] = _BASE_EPOCH + 3665 # ~01:50:05
599581
check.data_observability.run_job()
600582

@@ -698,16 +680,6 @@ def test_lateness_metric_emitted_for_cron(pg_instance, aggregator, monkeypatch):
698680
"""Cron query fired late emits a positive lateness gauge with mode:cron tag."""
699681
# next_run will be at 00:50:00; fire at 00:52:00 → 120s lateness
700682
fire_time = float(_BASE_EPOCH + 180) # 00:52:00
701-
702-
call_count = [0]
703-
704-
def fake_clock():
705-
call_count[0] += 1
706-
# First call: _get_due_queries registration at tick_time
707-
# The sequence is complex; just use a simpler two-phase approach:
708-
# Use monkeypatch to control time directly via a mutable list.
709-
return current_time[0]
710-
711683
current_time = [float(_BASE_EPOCH)] # 00:49:00 — before the tick
712684
monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0])
713685

@@ -780,26 +752,9 @@ def test_lateness_clamped_at_zero(pg_instance, aggregator, monkeypatch):
780752
check = _make_cron_check(pg_instance)
781753
check.db_pool = _mock_db_pool(mock_conn)
782754

783-
# First call: registers next_run = 01:50:00 (future)
784-
check.data_observability.run_job()
785-
mid = CRON_QUERY['monitor_id']
786-
787-
# Manually set next_run to a value slightly in the future to simulate skew
788-
# (i.e., scheduled_fire_time > now_at_fire_start)
789-
check.data_observability._next_run[mid] = current_time[0] + 1000.0
790-
791-
# Force it to look "due" by setting now past that point but making now_at_fire_start < scheduled
792-
# We achieve this by having _get_due_queries return a past-tick but then fire_start < scheduled_tick.
793-
# Simpler approach: directly manipulate state so the query is seen as due with scheduled > fire_start.
794-
# Set next_run to a past value so it's due:
795-
past_tick = current_time[0] - 5.0
796-
check.data_observability._next_run[mid] = past_tick
797-
# But shift time so now_at_fire_start < past_tick (synthetic skew):
798-
# Actually, just verify the clamp: if scheduled_fire_time > now_at_fire_start, value = 0.
799-
# We construct this by running at a time just after next_run but having time.time()
800-
# return a value LESS than next_run for now_at_fire_start — achieved by decrementing after due check.
801-
# The cleanest way: patch _get_due_queries directly.
802-
skewed_scheduled = current_time[0] + 100.0 # scheduled in the future
755+
# Patch _get_due_queries to return a scheduled_fire_time in the future of fire_start
756+
# (synthetic clock-skew scenario). Lateness must clamp to 0 rather than go negative.
757+
skewed_scheduled = current_time[0] + 100.0
803758
with patch.object(
804759
check.data_observability,
805760
'_get_due_queries',
@@ -951,3 +906,41 @@ def test_cron_startup_lookback_advances_next_run_past_recovered_tick(pg_instance
951906
next_run = check.data_observability._next_run[mid]
952907
# Recovered fire at 00:50:10 should advance _next_run to 01:50:00, not to 00:50:00.
953908
assert next_run >= _BASE_EPOCH + 3600 # at least one hour past _BASE_EPOCH (00:49:00)
909+
910+
911+
def test_cron_startup_lookback_default_is_300_seconds(pg_instance):
912+
"""The default lookback window is 5 minutes; pin the constant so a regression is loud."""
913+
from datadog_checks.postgres.data_observability import DEFAULT_CRON_STARTUP_LOOKBACK_SECONDS
914+
915+
assert DEFAULT_CRON_STARTUP_LOOKBACK_SECONDS == 300
916+
check = _make_cron_lookback_check(pg_instance, lookback_seconds=None)
917+
assert check.data_observability._cron_startup_lookback_seconds() == 300
918+
919+
920+
def test_cron_startup_lookback_negative_clamped_to_zero_with_warning(pg_instance, caplog):
921+
"""A negative lookback value clamps to 0 and emits a warning so it isn't silently dropped."""
922+
import logging
923+
924+
check = _make_cron_lookback_check(pg_instance, lookback_seconds=-100)
925+
with caplog.at_level(logging.WARNING, logger=check.data_observability._log.name):
926+
result = check.data_observability._cron_startup_lookback_seconds()
927+
assert result == 0
928+
assert any('Invalid cron_startup_lookback_seconds' in r.message for r in caplog.records)
929+
930+
931+
def test_cron_startup_lookback_does_not_double_fire(pg_instance, aggregator, monkeypatch):
932+
"""A startup-recovery fire must not re-fire the same tick on the next run_job() call."""
933+
current_time = [float(_BASE_EPOCH + 70)] # 00:50:10 — inside the default 300s lookback
934+
monkeypatch.setattr('datadog_checks.postgres.data_observability.time.time', lambda: current_time[0])
935+
936+
mock_conn, _ = _make_mock_conn()
937+
check = _make_cron_lookback_check(pg_instance, lookback_seconds=None)
938+
check.db_pool = _mock_db_pool(mock_conn)
939+
check.data_observability.run_job()
940+
assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 1
941+
942+
# Advance a few seconds (still well before the next 01:50 tick); a second run must not re-fire.
943+
current_time[0] = _BASE_EPOCH + 80
944+
aggregator.reset()
945+
check.data_observability.run_job()
946+
assert len(aggregator.metrics('dd.postgres.data_observability.query_executions')) == 0

0 commit comments

Comments
 (0)