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
45from unittest import mock
56
67import pytest
78
89from datadog_checks .base .checks .db import DatabaseCheck
910from datadog_checks .base .stubs .datadog_agent import datadog_agent
11+ from datadog_checks .base .utils .db .utils import DBMAsyncJob
1012
1113
1214class 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+
2665def 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