Skip to content

Commit 6672354

Browse files
sap_hana: address DO review feedback (cancel, query key, query_timeout)
Key the Data Observability scheduler off the monitor_id when the RC payload carries a real (non-zero) one — the forward-looking contract shared with Postgres — and fall back to a hash of the query text otherwise. This matters because the payload delivered to agents today carries no per-query monitor_id (it decodes to 0/None); keying purely off monitor_id would collapse every query onto 0. The query-text fallback is safe because the SQL embeds the monitor id(s) it serves (in a trailing Datadog comment, or the column alias for custom SQL), so distinct monitors always produce distinct query text. The key upgrades to monitor_id automatically once the backend starts emitting it. Rename the per-query timeout_seconds option to query_timeout in milliseconds to align with the Postgres DO config and the agent RC handler, which delivers query_timeout as timeout_seconds * 1000. Add SapHanaCheck.cancel() so the DBMAsyncJob thread pool is released when the check is unscheduled (cluster-agent flavor / one-off runs). Apply reviewer doc suggestions in the README and spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 38c8e1c commit 6672354

6 files changed

Lines changed: 76 additions & 44 deletions

File tree

sap_hana/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ See the [sample sap_hana.d/conf.yaml][4] for all available schema collection opt
153153

154154
The Datadog backend can deliver monitoring queries to the SAP HANA check via Remote Configuration. When enabled, the Agent executes these queries against HANA on a schedule and forwards the results as Data Observability events.
155155

156-
To allow Remote Configuration to push query configs to the `sap_hana` check, add it to the allow list in `datadog.yaml`:
156+
To allow Remote Configuration to push query configs to the `sap_hana` check, add `sap_hana` to the allowlist in `datadog.yaml`:
157157

158158
```yaml
159159
remote_configuration:
@@ -162,9 +162,9 @@ remote_configuration:
162162
- sap_hana
163163
```
164164

165-
Without this entry, the Agent silently drops any query dispatched by the backend with no error surfaced. After updating `datadog.yaml`, [restart the Agent][5].
165+
Without this entry, the Agent silently drops any query delivered by the backend without surfacing an error. After updating `datadog.yaml`, [restart the Agent][5].
166166

167-
Data Observability query actions require schema collection to be enabled. Ensure the `collect_schemas` block is present and `enabled: true` in your `sap_hana.d/conf.yaml` (see [Schema collection](#schema-collection)).
167+
Data Observability query actions require schema collection to be enabled. Verify that the `collect_schemas` block is present and `enabled: true` in your `sap_hana.d/conf.yaml` (see [Schema collection](#schema-collection)).
168168

169169
### Validation
170170

sap_hana/assets/configuration/spec.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ files:
161161
fleet_configurable: true
162162
description: |
163163
Configure the Data Observability async job, which executes monitoring
164-
queries delivered via Remote Configuration.
164+
queries delivered through Remote Configuration.
165165
options:
166166
- name: enabled
167167
fleet_configurable: true
@@ -224,9 +224,9 @@ files:
224224
schedule wins and interval_seconds is ignored. If neither is set, the
225225
query is skipped at runtime with a warning.
226226
type: string
227-
- name: timeout_seconds
227+
- name: query_timeout
228228
description: |
229-
Statement timeout for this query in seconds. Applied as a connection-level
229+
Statement timeout for this query in milliseconds. Applied as a connection-level
230230
statementTimeout when executing the query.
231231
type: integer
232232
- name: dbname

sap_hana/datadog_checks/sap_hana/config_models/instance.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,13 @@ class Query(BaseModel):
8484
)
8585
monitor_id: Optional[int] = None
8686
query: str
87-
schedule: Optional[str] = Field(
87+
query_timeout: Optional[int] = Field(
8888
None,
89-
description='A standard 5-field cron expression (minute hour dom month dow) specifying\nwhen to run this query. When both schedule and interval_seconds are set,\nschedule wins and interval_seconds is ignored. If neither is set, the\nquery is skipped at runtime with a warning.\n',
89+
description='Statement timeout for this query in milliseconds. Applied as a connection-level\nstatementTimeout when executing the query.\n',
9090
)
91-
timeout_seconds: Optional[int] = Field(
91+
schedule: Optional[str] = Field(
9292
None,
93-
description='Statement timeout for this query in seconds. Applied as a connection-level\nstatementTimeout when executing the query.\n',
93+
description='A standard 5-field cron expression (minute hour dom month dow) specifying\nwhen to run this query. When both schedule and interval_seconds are set,\nschedule wins and interval_seconds is ignored. If neither is set, the\nquery is skipped at runtime with a warning.\n',
9494
)
9595
type: Optional[str] = None
9696

sap_hana/datadog_checks/sap_hana/data_observability.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,23 @@
2929

3030
CRON_STARTUP_LOOKBACK_SECONDS = 300
3131

32-
DEFAULT_DO_QUERY_TIMEOUT_S = 60
32+
DEFAULT_DO_QUERY_TIMEOUT_MS = 60_000
3333

3434
Mode = Literal["cron", "interval"]
3535

3636

3737
def _query_key(q: Query) -> str:
38-
"""Stable per-query scheduling key derived from the query text."""
38+
"""Stable per-query scheduling key.
39+
40+
Prefer the monitor id when the RC payload carries a real one — this is the
41+
forward-looking contract shared with Postgres. Today the payload delivers no
42+
per-query monitor id (it decodes to 0/None), so fall back to a hash of the query
43+
text. That is sufficient because the SQL embeds the monitor id(s) it serves (in a
44+
trailing "-- Datadog {\"monitor_ids\":[...]}" comment, or the column alias for
45+
custom SQL), so distinct monitors always produce distinct query text.
46+
"""
47+
if q.monitor_id: # real, non-zero monitor id
48+
return f"monitor:{q.monitor_id}"
3949
return hashlib.sha256(q.query.encode()).hexdigest()[:16]
4050

4151

@@ -53,7 +63,7 @@ def __init__(self, check: SapHanaCheck, do_config: DataObservability) -> None:
5363
self._do_config = do_config
5464
self._last_execution: dict[str, float] = {}
5565
self._do_conn: Any = None
56-
self._do_conn_timeout_s: int | None = None
66+
self._do_conn_timeout_ms: int | None = None
5767

5868
collection_interval = do_config.collection_interval or 10
5969
super().__init__(
@@ -140,15 +150,15 @@ def _build_base_tags(self) -> list[str]:
140150
tags.append('db_type:saphana')
141151
return tags
142152

143-
def _get_connection(self, timeout_seconds: int) -> Any:
153+
def _get_connection(self, timeout_ms: int) -> Any:
144154
"""Return the persistent DO connection, (re)creating it when the timeout changes."""
145-
if self._do_conn is not None and self._do_conn_timeout_s == timeout_seconds:
155+
if self._do_conn is not None and self._do_conn_timeout_ms == timeout_ms:
146156
return self._do_conn
147157
if self._do_conn is not None:
148158
self._log.debug(
149-
"Data Observability: reopening DO connection (timeout changed from %ds to %ds).",
150-
self._do_conn_timeout_s,
151-
timeout_seconds,
159+
"Data Observability: reopening DO connection (timeout changed from %dms to %dms).",
160+
self._do_conn_timeout_ms,
161+
timeout_ms,
152162
)
153163
try:
154164
self._do_conn.close()
@@ -161,7 +171,7 @@ def _get_connection(self, timeout_seconds: int) -> Any:
161171
# its own connection (instead of reusing self._check._conn) because it
162172
# needs a per-query statementTimeout that must not affect main-check queries.
163173
conn_props = self._check._get_connection_properties()
164-
conn_props['statementTimeout'] = timeout_seconds * 1000
174+
conn_props['statementTimeout'] = timeout_ms
165175
try:
166176
self._do_conn = hana_connect(**conn_props)
167177
except HanaError as e:
@@ -174,14 +184,14 @@ def _get_connection(self, timeout_seconds: int) -> Any:
174184
raise
175185
self._log.debug(
176186
"Data Observability: DO connection opened (statementTimeout=%dms).",
177-
timeout_seconds * 1000,
187+
timeout_ms,
178188
)
179-
self._do_conn_timeout_s = timeout_seconds
189+
self._do_conn_timeout_ms = timeout_ms
180190
return self._do_conn
181191

182192
def _execute_single_query(self, query_spec: Query) -> dict[str, Any]:
183-
timeout_s = query_spec.timeout_seconds or DEFAULT_DO_QUERY_TIMEOUT_S
184-
conn = self._get_connection(timeout_s)
193+
timeout_ms = query_spec.query_timeout or DEFAULT_DO_QUERY_TIMEOUT_MS
194+
conn = self._get_connection(timeout_ms)
185195
start = time.time()
186196
try:
187197
if self._cancel_event.is_set():
@@ -211,7 +221,7 @@ def _execute_single_query(self, query_spec: Query) -> dict[str, Any]:
211221
)
212222
self._log.warning("Data Observability: resetting DO connection after query failure.")
213223
self._do_conn = None
214-
self._do_conn_timeout_s = None
224+
self._do_conn_timeout_ms = None
215225
return {
216226
'status': 'error',
217227
'columns': [],

sap_hana/datadog_checks/sap_hana/sap_hana.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ def check(self, _):
159159
)
160160
self._connection_flaked = False
161161

162+
def cancel(self):
163+
# Signal the Data Observability async job to stop so its executor thread is
164+
# released when the check is unscheduled (e.g. cluster-agent flavor or one-off
165+
# check invocations), instead of leaking the DBMAsyncJob thread pool.
166+
self.data_observability.cancel()
167+
162168
def set_default_methods(self):
163169
self._default_methods.extend(
164170
[

sap_hana/tests/test_data_observability.py

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from datadog_checks.sap_hana.config_models.instance import CustomSqlSelectFields, Entity, Query
1313
from datadog_checks.sap_hana.data_observability import (
14-
DEFAULT_DO_QUERY_TIMEOUT_S,
14+
DEFAULT_DO_QUERY_TIMEOUT_MS,
1515
SapHanaDataObservability,
1616
_query_key,
1717
)
@@ -39,7 +39,7 @@ def _make_do(queries=(), config_id=None, tags=None):
3939
do._do_config = do_config
4040
do._last_execution = {}
4141
do._do_conn = None
42-
do._do_conn_timeout_s = None
42+
do._do_conn_timeout_ms = None
4343
do._log = mock.MagicMock()
4444
do._cancel_event = threading.Event()
4545
do._tags = tags
@@ -52,15 +52,15 @@ def _q(
5252
interval_seconds=None,
5353
schedule=None,
5454
monitor_id=None,
55-
timeout_seconds=None,
55+
query_timeout=None,
5656
dbname=None,
5757
):
5858
return Query(
5959
query=sql,
6060
interval_seconds=interval_seconds,
6161
schedule=schedule,
6262
monitor_id=monitor_id,
63-
timeout_seconds=timeout_seconds,
63+
query_timeout=query_timeout,
6464
dbname=dbname,
6565
)
6666

@@ -79,17 +79,33 @@ def _mock_conn(columns, rows):
7979

8080

8181
class TestQueryKey:
82-
def test_same_sql_same_key(self):
82+
def test_same_query_same_key(self):
8383
assert _query_key(_q('SELECT 1')) == _query_key(_q('SELECT 1'))
8484

8585
def test_different_sql_different_key(self):
86+
# Distinct monitors always yield distinct SQL (monitor ids are embedded in the query).
8687
assert _query_key(_q('SELECT 1')) != _query_key(_q('SELECT 2'))
8788

8889
def test_key_is_16_hex_chars(self):
8990
key = _query_key(_q('SELECT 1'))
9091
assert len(key) == 16
9192
assert all(c in '0123456789abcdef' for c in key)
9293

94+
def test_nonzero_monitor_id_used_as_key(self):
95+
assert _query_key(_q('SELECT 1', monitor_id=42)) == 'monitor:42'
96+
97+
def test_different_monitor_ids_different_key(self):
98+
# With a real monitor id, identity comes from the id, not the SQL.
99+
assert _query_key(_q('SELECT 1', monitor_id=1)) != _query_key(_q('SELECT 1', monitor_id=2))
100+
101+
def test_same_monitor_id_same_key_regardless_of_sql(self):
102+
assert _query_key(_q('SELECT 1', monitor_id=7)) == _query_key(_q('SELECT 2', monitor_id=7))
103+
104+
def test_zero_monitor_id_falls_back_to_query_hash(self):
105+
# monitor_id 0 (the omitempty default) and None both fall back to the query hash.
106+
assert _query_key(_q('SELECT 1', monitor_id=0)) == _query_key(_q('SELECT 1', monitor_id=None))
107+
assert _query_key(_q('SELECT 1', monitor_id=0)) == _query_key(_q('SELECT 1'))
108+
93109

94110
# ── _filter_valid_queries ──────────────────────────────────────────────────────
95111

@@ -121,11 +137,11 @@ def test_invalid_cron_rejected(self):
121137
assert do._queries == ()
122138

123139
def test_mixed_valid_and_invalid(self):
124-
queries = [_q(interval_seconds=30), _q(), _q(schedule='*/10 * * * *')]
140+
queries = [_q('SELECT 1', interval_seconds=30), _q('SELECT 2'), _q('SELECT 3', schedule='*/10 * * * *')]
125141
do = _make_do(queries)
126142
assert len(do._queries) == 2
127143

128-
def test_cron_scheduler_keyed_by_query_hash(self):
144+
def test_cron_scheduler_keyed_by_query_key(self):
129145
q = _q(schedule='0 * * * *')
130146
do = _make_do([q])
131147
assert _query_key(q) in do._schedulers
@@ -136,7 +152,7 @@ def test_interval_query_has_no_scheduler(self):
136152
assert _query_key(q) not in do._schedulers
137153

138154
def test_skipped_queries_emit_warning(self):
139-
do = _make_do([_q(), _q(interval_seconds=30)])
155+
do = _make_do([_q('SELECT 1'), _q('SELECT 2', interval_seconds=30)])
140156
do._log.warning.assert_called()
141157

142158

@@ -237,7 +253,7 @@ def test_monitor_id_present_when_set(self):
237253
assert payload['monitor_id'] == 42
238254

239255
def test_monitor_id_absent_when_none(self):
240-
q = _q()
256+
q = _q() # monitor_id is None
241257
payload = _make_do()._build_event_payload(q, self._RESULT)
242258
assert 'monitor_id' not in payload
243259

@@ -300,7 +316,7 @@ def test_success_returns_columns_and_rows(self):
300316
do = _make_do([q])
301317
conn, _ = _mock_conn(['id', 'name'], [[1, 'alice'], [2, 'bob']])
302318
do._do_conn = conn
303-
do._do_conn_timeout_s = DEFAULT_DO_QUERY_TIMEOUT_S
319+
do._do_conn_timeout_ms = DEFAULT_DO_QUERY_TIMEOUT_MS
304320

305321
result = do._execute_single_query(q)
306322

@@ -318,7 +334,7 @@ def test_hana_error_returns_error_status(self):
318334
conn, cursor = _mock_conn(['id'], [])
319335
cursor.execute.side_effect = HanaError('connection lost')
320336
do._do_conn = conn
321-
do._do_conn_timeout_s = DEFAULT_DO_QUERY_TIMEOUT_S
337+
do._do_conn_timeout_ms = DEFAULT_DO_QUERY_TIMEOUT_MS
322338

323339
result = do._execute_single_query(q)
324340

@@ -335,34 +351,34 @@ def test_hana_error_resets_connection(self):
335351
conn, cursor = _mock_conn([], [])
336352
cursor.execute.side_effect = HanaError('timeout')
337353
do._do_conn = conn
338-
do._do_conn_timeout_s = DEFAULT_DO_QUERY_TIMEOUT_S
354+
do._do_conn_timeout_ms = DEFAULT_DO_QUERY_TIMEOUT_MS
339355

340356
do._execute_single_query(q)
341357

342358
assert do._do_conn is None
343-
assert do._do_conn_timeout_s is None
359+
assert do._do_conn_timeout_ms is None
344360

345361
def test_uses_default_timeout_when_none(self):
346-
q = _q(interval_seconds=60) # timeout_seconds is None
362+
q = _q(interval_seconds=60) # query_timeout is None
347363
do = _make_do([q])
348364
called_with = []
349365

350-
def fake_get_connection(timeout_s):
351-
called_with.append(timeout_s)
366+
def fake_get_connection(timeout_ms):
367+
called_with.append(timeout_ms)
352368
conn, _ = _mock_conn(['x'], [])
353369
return conn
354370

355371
do._get_connection = fake_get_connection
356372
do._execute_single_query(q)
357-
assert called_with == [DEFAULT_DO_QUERY_TIMEOUT_S]
373+
assert called_with == [DEFAULT_DO_QUERY_TIMEOUT_MS]
358374

359375
def test_uses_query_timeout_when_set(self):
360-
q = _q(interval_seconds=60, timeout_seconds=120)
376+
q = _q(interval_seconds=60, query_timeout=120)
361377
do = _make_do([q])
362378
called_with = []
363379

364-
def fake_get_connection(timeout_s):
365-
called_with.append(timeout_s)
380+
def fake_get_connection(timeout_ms):
381+
called_with.append(timeout_ms)
366382
conn, _ = _mock_conn(['x'], [])
367383
return conn
368384

0 commit comments

Comments
 (0)