Skip to content

Commit f2a39eb

Browse files
authored
[DBMON-6812] Add async job registry to DatabaseCheck (DataDog#24442)
* Add job registration to DatabaseCheck * Add changelog * Update variable to avoid collision with Postgres existing usage * Add type hints to new public functions * Remove None handling
1 parent 366c2a2 commit f2a39eb

4 files changed

Lines changed: 181 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add an async job registry to `DatabaseCheck` so DBM integrations can register `DBMAsyncJob`s additively and run, cancel, or shut them all down through a single entry point (`register_async_job`, `run_async_jobs`, `cancel_async_jobs`, `shutdown_async_jobs`), along with a `DBMAsyncJob.shutdown()` hook for releasing lifetime-scoped resources on unschedule.

datadog_checks_base/datadog_checks/base/checks/db.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@
44

55
from abc import abstractmethod
66
from string import Template
7+
from typing import TYPE_CHECKING, Dict, List
78

89
from datadog_checks.base.agent import datadog_agent
910
from datadog_checks.base.utils.db.utils import TagManager
1011

1112
from . import AgentCheck
1213

14+
if TYPE_CHECKING:
15+
from datadog_checks.base.utils.db.utils import DBMAsyncJob
16+
1317

1418
class DatabaseCheck(AgentCheck):
19+
"""
20+
Base class for Database Monitoring (DBM) integrations.
21+
"""
22+
1523
#: Authoritative DBM platform identifier for this integration.
1624
#: Subclasses should set this explicitly; it is the value surfaced by
1725
#: :attr:`dbms` and used across DBM payloads, metric name prefixes and async jobs.
@@ -23,6 +31,49 @@ def __init__(self, *args, **kwargs):
2331
self._database_identifier = None
2432
self._dbms_fallback_warning_logged = False
2533
self.tag_manager = TagManager()
34+
#: Async jobs owned by this check, keyed by job name, populated via
35+
#: :meth:`register_async_job`.
36+
self._async_job_registry: Dict[str, "DBMAsyncJob"] = {}
37+
38+
def register_async_job(self, job: "DBMAsyncJob") -> "DBMAsyncJob":
39+
"""
40+
Register ``job`` under its ``job_name`` so the check manages its lifecycle, and return it
41+
unchanged.
42+
43+
Registering a job whose name matches an already-registered job replaces it. Raises
44+
``ValueError`` if the job has no name.
45+
"""
46+
if job.job_name is None:
47+
raise ValueError("Cannot register an async job without a job_name")
48+
self._async_job_registry[job.job_name] = job
49+
return job
50+
51+
def run_async_jobs(self, tags: List[str]) -> None:
52+
"""Run each registered job's loop, forwarding ``tags`` to every job."""
53+
for job in self._async_job_registry.values():
54+
job.run_job_loop(tags)
55+
56+
def cancel_async_jobs(self) -> None:
57+
"""
58+
Signal every registered job to stop, without waiting for loops to finish or releasing
59+
resources.
60+
61+
Safe to call while ``check()`` is running. Follow with :meth:`shutdown_async_jobs` to wait
62+
for the loops and release resources.
63+
"""
64+
for job in self._async_job_registry.values():
65+
job.cancel()
66+
67+
def shutdown_async_jobs(self) -> None:
68+
"""
69+
Wait for every registered job's loop to finish (:meth:`~DBMAsyncJob.wait_for_completion`)
70+
and run its teardown (:meth:`~DBMAsyncJob.shutdown`).
71+
72+
Must not run concurrently with ``check()``.
73+
"""
74+
for job in self._async_job_registry.values():
75+
job.wait_for_completion()
76+
job.shutdown()
2677

2778
def database_monitoring_query_sample(self, raw_event: str):
2879
self.event_platform_event(raw_event, "dbm-samples")

datadog_checks_base/datadog_checks/base/utils/db/utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,12 +345,26 @@ def __init__(
345345
if self._features is None:
346346
self._features = [None]
347347

348+
@property
349+
def job_name(self) -> Optional[str]:
350+
"""The job's name"""
351+
return self._job_name
352+
348353
def cancel(self):
349354
"""
350355
Send a signal to cancel the job loop asynchronously.
351356
"""
352357
self._cancel_event.set()
353358

359+
def wait_for_completion(self) -> None:
360+
"""
361+
Block until the job loop has finished running, then clear its future. No-op if the loop is
362+
not running. Typically called after :meth:`cancel` to wait for the loop to stop.
363+
"""
364+
if self._job_loop_future:
365+
self._job_loop_future.result()
366+
self._job_loop_future = None
367+
354368
def run_job_loop(self, tags):
355369
"""
356370
:param tags:
@@ -467,6 +481,8 @@ def _job_loop(self):
467481
)
468482
finally:
469483
self._log.info("[%s] Shutting down job loop", self._job_tags_str)
484+
# Runs on every loop exit, including the inactivity stop above, after which the loop may
485+
# restart on the next check run. For one-time teardown on unschedule, override shutdown().
470486
if self._shutdown_callback:
471487
self._shutdown_callback()
472488

@@ -498,6 +514,19 @@ def _run_job_traced(self):
498514
def run_job(self):
499515
raise NotImplementedError()
500516

517+
def shutdown(self) -> None:
518+
"""
519+
Release resources the job holds for its whole lifetime, such as dedicated DB connections or
520+
clients.
521+
522+
Called once when the owning check is unscheduled, after the loop has stopped. The default
523+
is a no-op; override to close long-lived resources, and keep the implementation idempotent.
524+
525+
Unlike ``shutdown_callback``, which runs on every loop exit and may be followed by a
526+
restart, this runs only during final teardown.
527+
"""
528+
pass
529+
501530

502531
@contextlib.contextmanager
503532
def tracked_query(check, operation, tags=None):

datadog_checks_base/tests/base/checks/test_database_check.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# (C) Datadog, Inc. 2026-present
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from concurrent.futures.thread import ThreadPoolExecutor
45
from unittest import mock
56

67
import pytest
78

89
from datadog_checks.base.checks.db import DatabaseCheck
910
from datadog_checks.base.stubs.datadog_agent import datadog_agent
11+
from datadog_checks.base.utils.db.utils import DBMAsyncJob
1012

1113

1214
class FakeDatabaseCheck(DatabaseCheck):
@@ -23,6 +25,43 @@ def cloud_metadata(self):
2325
return {}
2426

2527

28+
class RegistryTestJob(DBMAsyncJob):
29+
"""Minimal DBMAsyncJob used to exercise the DatabaseCheck async job registry."""
30+
31+
def __init__(self, check, enabled=True, job_name="test-job"):
32+
super().__init__(
33+
check,
34+
enabled=enabled,
35+
dbms="test-dbms",
36+
rate_limit=10,
37+
max_sleep_chunk_s=0.1,
38+
job_name=job_name,
39+
)
40+
self.shutdown_calls = 0
41+
42+
def shutdown(self):
43+
self.shutdown_calls += 1
44+
45+
def run_job(self):
46+
pass
47+
48+
49+
@pytest.fixture
50+
def registry_check():
51+
check = FakeDatabaseCheck("test", {}, [{}])
52+
yield check
53+
# Stop any registered jobs so their loops don't outlive the test.
54+
check.cancel_async_jobs()
55+
check.shutdown_async_jobs()
56+
57+
58+
@pytest.fixture(autouse=True)
59+
def stop_orphaned_threads():
60+
# Recreate the shared executor per test so job loops don't leak across tests.
61+
DBMAsyncJob.executor.shutdown(wait=True)
62+
DBMAsyncJob.executor = ThreadPoolExecutor()
63+
64+
2665
def test_agent_hostname_resolves_once_and_caches():
2766
check = FakeDatabaseCheck("test", {}, [{}])
2867
# The hostname comes from an FFI call, so it should only be resolved once and cached.
@@ -89,3 +128,64 @@ def database_identifier_params(self):
89128
if tags_after is not None:
90129
check.tag_manager.set_tags_from_list(tags_after, replace=True)
91130
assert check.database_identifier == expected
131+
132+
133+
@pytest.mark.parametrize("register_twice", [False, True], ids=["single", "duplicate_instance"])
134+
def test_register_async_job_adds_and_dedupes(registry_check, register_twice):
135+
job = RegistryTestJob(registry_check)
136+
assert registry_check.register_async_job(job) is job
137+
if register_twice:
138+
assert registry_check.register_async_job(job) is job
139+
assert registry_check._async_job_registry == {"test-job": job}
140+
141+
142+
def test_register_async_job_replaces_job_with_same_name(registry_check):
143+
first = RegistryTestJob(registry_check, job_name="query-metrics")
144+
second = RegistryTestJob(registry_check, job_name="query-metrics")
145+
146+
registry_check.register_async_job(first)
147+
registry_check.register_async_job(second)
148+
149+
assert registry_check._async_job_registry == {"query-metrics": second}
150+
151+
152+
def test_register_async_job_requires_job_name(registry_check):
153+
with pytest.raises(ValueError):
154+
registry_check.register_async_job(RegistryTestJob(registry_check, job_name=None))
155+
156+
157+
@pytest.mark.parametrize("enabled", [True, False], ids=["enabled", "disabled"])
158+
def test_run_async_jobs_starts_only_enabled_jobs(registry_check, enabled):
159+
job = registry_check.register_async_job(RegistryTestJob(registry_check, enabled=enabled))
160+
161+
registry_check.run_async_jobs([])
162+
163+
# Only enabled jobs get a running loop; disabled jobs are skipped by run_job_loop.
164+
assert (job._job_loop_future is not None) == enabled
165+
166+
167+
def test_cancel_async_jobs_signals_without_touching_futures(registry_check):
168+
job = registry_check.register_async_job(RegistryTestJob(registry_check))
169+
registry_check.run_async_jobs([])
170+
assert job._job_loop_future is not None
171+
172+
registry_check.cancel_async_jobs()
173+
174+
# cancel_async_jobs only sets the cancel event; the future stays in place for shutdown to await.
175+
assert job._cancel_event.is_set()
176+
assert job._job_loop_future is not None
177+
178+
179+
@pytest.mark.parametrize("started", [True, False], ids=["loop_started", "loop_not_started"])
180+
def test_shutdown_async_jobs_tears_down_and_calls_shutdown(registry_check, started):
181+
job = registry_check.register_async_job(RegistryTestJob(registry_check))
182+
if started:
183+
registry_check.run_async_jobs([])
184+
assert job._job_loop_future is not None
185+
registry_check.cancel_async_jobs()
186+
187+
registry_check.shutdown_async_jobs()
188+
189+
# The future is cleared and shutdown runs once, whether or not a loop was started.
190+
assert job._job_loop_future is None
191+
assert job.shutdown_calls == 1

0 commit comments

Comments
 (0)