Skip to content

Commit 42c5142

Browse files
sap_hana: run schema collection in a DBMAsyncJob background thread (#24128)
* sap_hana: run schema collection in a DBMAsyncJob background thread Schema collection previously blocked the main check loop on every run. Wrap HanaSchemaCollector in HanaSchemaCollectionJob (DBMAsyncJob) so it runs in a background thread at the configured collection_interval, using its own dedicated hdbcli connection — the same pattern used by the DO job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sap_hana: add changelog entry for async schema collection PR #24128 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sap_hana: address review feedback on schema collection job - cancel() now stops the schema collection job too, so its background thread and dedicated HANA connection are released promptly on teardown instead of lingering until the inactivity timeout. - Reset the job connection when a HANA error is swallowed inside collect_schemas(): the base SchemaCollector catches per-database errors and returns, so a transient disconnect never reached run_job's handler and the dead connection was reused every cycle. The collector now drops its connection reference on HanaError and the job reconnects next cycle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sap_hana: correct misleading cancel() comment cancel() only sets each job's cancel event; it does not itself close the schema job's dedicated HANA connection. Reword the comment to reflect what cancel() actually does, per DBM review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sap_hana: harden schema job connection and version handling Address review feedback on the async schema collection job: - Clamp a fractional collection_interval (< 1) so it no longer truncates to 0 and raises ZeroDivisionError while building rate_limit, which took down the whole check on construction. - Replace the overloaded _conn sentinel with an explicit set_connection() + connection_lost contract, and drop the dead check._conn fallback so the catalog query can never silently run on the main check connection. - Resolve dbms_version on the schema job's dedicated connection via _resolve_dbms_version(conn) instead of querying the main check connection off-thread, removing the race and the 'unknown' poisoning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c7893e3 commit 42c5142

6 files changed

Lines changed: 247 additions & 107 deletions

File tree

sap_hana/assets/configuration/spec.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,13 @@ files:
178178
type: string
179179
example:
180180
- "TMP_SCHEMA"
181+
- name: run_sync
182+
hidden: true
183+
description: Run the schema collection job synchronously.
184+
value:
185+
type: boolean
186+
example: false
187+
display_default: false
181188
- name: data_observability
182189
hidden: true
183190
display_priority: 0

sap_hana/changelog.d/24128.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Run SAP HANA schema collection in a background thread to avoid blocking the main check loop.

sap_hana/datadog_checks/sap_hana/config_models/instance.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class CollectSchemas(BaseModel):
3535
max_columns: Optional[int] = None
3636
max_tables: Optional[int] = None
3737
max_views: Optional[int] = None
38+
run_sync: Optional[bool] = None
3839

3940

4041
class CustomQuery(BaseModel):

sap_hana/datadog_checks/sap_hana/sap_hana.py

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from __future__ import division
55

66
import functools
7-
import time
87
from collections import defaultdict
98
from contextlib import closing
109
from datetime import datetime
@@ -32,7 +31,7 @@
3231
from .data_observability import SapHanaDataObservability
3332
from .diagnose import run_diagnostics
3433
from .exceptions import OperationalError, QueryExecutionError
35-
from .schemas import HanaSchemaCollector
34+
from .schemas import HanaSchemaCollectionJob
3635
from .utils import compute_percent, positive
3736

3837

@@ -93,11 +92,9 @@ def __init__(self, name, init_config, instances):
9392
# Save master database hostname to act as the default if `use_hana_hostnames` is true
9493
self._master_hostname = None
9594

96-
# Schema collection (DBM)
95+
# Schema collection async job (DBM)
9796
collect_schemas = CollectSchemas(**(self.instance.get('collect_schemas') or {}))
98-
self._schema_collector = HanaSchemaCollector(self, collect_schemas) if collect_schemas.enabled else None
99-
self._schema_collection_interval = int(collect_schemas.collection_interval or 600)
100-
self._last_schema_collection_time = 0
97+
self._schema_collection_job = HanaSchemaCollectionJob(self, collect_schemas)
10198
self._dbms_version = None
10299

103100
# Data Observability async job (RC-delivered queries)
@@ -131,7 +128,7 @@ def check(self, _):
131128
except Exception as e:
132129
self.log.exception('Unexpected error running `%s`: %s', query_method.__name__, str(e))
133130
continue
134-
self._maybe_collect_schemas()
131+
self._schema_collection_job.run_job_loop(self._tags)
135132
if self._do_config.enabled:
136133
self.data_observability.run_job_loop(self._tags)
137134
finally:
@@ -162,9 +159,11 @@ def check(self, _):
162159
self._connection_flaked = False
163160

164161
def cancel(self):
165-
# Signal the Data Observability async job to stop so its executor thread is
166-
# released when the check is unscheduled (e.g. cluster-agent flavor or one-off
167-
# check invocations), instead of leaking the DBMAsyncJob thread pool.
162+
# Signal both async jobs to stop so their executor threads are released when the
163+
# check is unscheduled (e.g. cluster-agent flavor or one-off check invocations),
164+
# instead of leaking the shared DBMAsyncJob thread pool. This only sets each job's
165+
# cancel event; it does not itself close the schema job's dedicated HANA connection.
166+
self._schema_collection_job.cancel()
168167
self.data_observability.cancel()
169168

170169
def set_default_methods(self):
@@ -220,16 +219,30 @@ def dbms(self):
220219

221220
@property
222221
def dbms_version(self):
223-
if self._dbms_version is None and self._conn is not None:
224-
try:
225-
with closing(self._conn.cursor()) as cursor:
226-
cursor.execute("SELECT VERSION FROM SYS.M_DATABASE")
227-
row = cursor.fetchone()
228-
self._dbms_version = str(row[0]).split()[0] if row else 'unknown'
229-
except Exception:
230-
self._dbms_version = 'unknown'
222+
# Only returns the cached value; resolution happens via _resolve_dbms_version on the
223+
# schema job's dedicated connection. The version is read from base_event on the job
224+
# thread, and querying self._conn here would race with the main check loop's concurrent
225+
# use of that same connection (there is no thread-safe pool, unlike DBM integrations).
231226
return self._dbms_version or 'unknown'
232227

228+
def _resolve_dbms_version(self, conn):
229+
"""Resolve and cache the HANA version using the given connection.
230+
231+
Called from the schema-collection job thread on its dedicated connection so the main
232+
check connection is never touched off-thread. Caches on first success; a transient
233+
failure leaves the value unresolved so the next cycle retries instead of caching
234+
'unknown' permanently.
235+
"""
236+
if self._dbms_version is not None:
237+
return
238+
try:
239+
with closing(conn.cursor()) as cursor:
240+
cursor.execute("SELECT VERSION FROM SYS.M_DATABASE")
241+
row = cursor.fetchone()
242+
self._dbms_version = str(row[0]).split()[0] if row else 'unknown'
243+
except Exception:
244+
pass
245+
233246
@property
234247
def tags(self):
235248
return self._tags
@@ -238,17 +251,6 @@ def tags(self):
238251
def cloud_metadata(self):
239252
return {}
240253

241-
def _maybe_collect_schemas(self):
242-
if not self._schema_collector or not self._conn:
243-
return
244-
if time.time() - self._last_schema_collection_time < self._schema_collection_interval:
245-
return
246-
try:
247-
self._schema_collector.collect_schemas()
248-
self._last_schema_collection_time = time.time()
249-
except Exception as e:
250-
self.log.error('Error collecting HANA schemas: %s', e)
251-
252254
def query_master_database(self):
253255
# https://help.sap.com/viewer/4fe29514fd584807ac9f2a04f6754767/2.0.02/en-US/20ae63aa7519101496f6b832ec86afbd.html
254256
# Only 1 database

sap_hana/datadog_checks/sap_hana/schemas.py

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,20 @@
4040

4141
import contextlib
4242
from contextlib import closing
43-
from typing import TYPE_CHECKING
43+
from typing import TYPE_CHECKING, Any
4444

4545
from datadog_checks.base.utils.db.schemas import DatabaseInfo, SchemaCollector, SchemaCollectorConfig
46+
from datadog_checks.base.utils.db.utils import DBMAsyncJob
4647

4748
if TYPE_CHECKING:
4849
from .config_models.instance import CollectSchemas
4950
from .sap_hana import SapHanaCheck
5051

52+
try:
53+
from hdbcli.dbapi import Error as HanaError
54+
except ImportError:
55+
HanaError = Exception # type: ignore[misc,assignment]
56+
5157
# Flush a schema payload once this many columns have accumulated. The base SchemaCollector
5258
# chunks payloads by table count (`payload_chunk_size`, default 10,000 tables), but a HANA
5359
# tenant is a single "database", so that threshold rarely trips: every table for the tenant
@@ -153,7 +159,10 @@
153159
class HanaSchemaCollectorConfig(SchemaCollectorConfig):
154160
def __init__(self, config: CollectSchemas):
155161
super().__init__()
156-
self.collection_interval = int(config.collection_interval or 600)
162+
# Guard against a non-positive interval (a sub-1 value truncates to 0), which would
163+
# otherwise ship as collection_interval: 0 in every schema payload.
164+
collection_interval = int(config.collection_interval or 600)
165+
self.collection_interval = collection_interval if collection_interval > 0 else 600
157166
self.max_tables = int(config.max_tables or 2000)
158167
self.max_views = int(config.max_views or 2000)
159168
self.max_columns = int(config.max_columns or 500)
@@ -233,6 +242,16 @@ def __init__(self, check: SapHanaCheck, config: CollectSchemas):
233242
super().__init__(check, HanaSchemaCollectorConfig(config))
234243
self._query_builder = HanaSchemaQueryBuilder(self._config, self._log)
235244
self._pending_row = None
245+
# The owning HanaSchemaCollectionJob injects its dedicated connection via
246+
# set_connection() before each cycle; connection_lost is the collector's signal
247+
# back to the job that the connection died mid-cycle and must be rebuilt.
248+
self._conn: Any = None
249+
self.connection_lost = False
250+
251+
def set_connection(self, conn: Any) -> None:
252+
"""Inject the job's dedicated connection for the upcoming collection cycle."""
253+
self._conn = conn
254+
self.connection_lost = False
236255

237256
def _reset(self) -> None:
238257
super()._reset()
@@ -254,7 +273,7 @@ def kind(self) -> str:
254273

255274
def _get_databases(self) -> list[DatabaseInfo]:
256275
try:
257-
with closing(self._check._conn.cursor()) as cursor:
276+
with closing(self._conn.cursor()) as cursor:
258277
cursor.execute(CURRENT_DATABASE_QUERY)
259278
row = cursor.fetchone()
260279
if not row:
@@ -271,21 +290,31 @@ def _get_databases(self) -> list[DatabaseInfo]:
271290
pass
272291
return [{'name': db_name, 'description': description}]
273292
except Exception as e:
293+
# A dead HANA connection surfaces here rather than propagating, so flag it for
294+
# the owning job to reconnect on the next cycle.
295+
if isinstance(e, HanaError):
296+
self.connection_lost = True
274297
self._log.warning("Could not determine current HANA database; skipping schema collection: %s", e)
275298
return []
276299

277300
@contextlib.contextmanager
278301
def _get_cursor(self, _database_name):
279-
conn = self._check._conn
280-
self._query_builder.ensure_stats_permission(conn)
281-
query, params = self._query_builder.build()
282-
with closing(conn.cursor()) as cursor:
283-
cursor.execute(query, params)
284-
self._pending_row = cursor.fetchone()
285-
try:
286-
yield cursor
287-
finally:
288-
self._pending_row = None
302+
conn = self._conn
303+
try:
304+
self._query_builder.ensure_stats_permission(conn)
305+
query, params = self._query_builder.build()
306+
with closing(conn.cursor()) as cursor:
307+
cursor.execute(query, params)
308+
self._pending_row = cursor.fetchone()
309+
try:
310+
yield cursor
311+
finally:
312+
self._pending_row = None
313+
except HanaError:
314+
# collect_schemas() swallows this per-database error, so flag the dead
315+
# connection to the owning job; it reconnects next cycle.
316+
self.connection_lost = True
317+
raise
289318

290319
def _get_next(self, cursor):
291320
"""Assemble one table/view from consecutive cursor rows sharing the same (schema, table) key."""
@@ -343,3 +372,68 @@ def _map_row(self, database: DatabaseInfo, table_row) -> dict:
343372
}
344373
],
345374
}
375+
376+
377+
class HanaSchemaCollectionJob(DBMAsyncJob):
378+
"""Background job that runs HanaSchemaCollector on its own dedicated connection."""
379+
380+
def __init__(self, check: SapHanaCheck, config: CollectSchemas) -> None:
381+
self._check = check
382+
self._schema_collector = HanaSchemaCollector(check, config)
383+
self._job_conn: Any = None
384+
# Cast to float (not int) so a sub-1 interval doesn't truncate to 0 and make the
385+
# rate_limit below divide by zero, which would crash the whole check on construction.
386+
# A non-positive value is meaningless as an interval, so clamp it back to the default.
387+
collection_interval = float(config.collection_interval or 600)
388+
if collection_interval <= 0:
389+
collection_interval = 600
390+
super().__init__(
391+
check,
392+
config_host=check._server,
393+
rate_limit=1.0 / collection_interval,
394+
run_sync=config.run_sync or False,
395+
enabled=config.enabled or False,
396+
dbms="saphana",
397+
min_collection_interval=check.instance.get('min_collection_interval', 15),
398+
expected_db_exceptions=(HanaError,),
399+
shutdown_callback=self._shutdown,
400+
job_name="schema-collection",
401+
)
402+
403+
def _shutdown(self) -> None:
404+
if self._job_conn is not None:
405+
try:
406+
self._job_conn.close()
407+
except Exception:
408+
pass
409+
self._job_conn = None
410+
411+
def _get_conn(self) -> Any:
412+
if self._job_conn is not None:
413+
return self._job_conn
414+
from hdbcli.dbapi import connect as hana_connect # noqa: PLC0415
415+
416+
props = self._check._get_connection_properties()
417+
self._job_conn = hana_connect(**props)
418+
return self._job_conn
419+
420+
def run_job(self) -> None:
421+
try:
422+
conn = self._get_conn()
423+
# Resolve the HANA version on this dedicated connection so base_event never
424+
# reads it off the main check's connection from this thread.
425+
self._check._resolve_dbms_version(conn)
426+
self._schema_collector.set_connection(conn)
427+
self._schema_collector.collect_schemas()
428+
except HanaError:
429+
self._reset_conn()
430+
raise
431+
# collect_schemas() swallows per-database HANA errors internally, so a transient
432+
# disconnect never reaches the except above. The collector flags connection_lost
433+
# in that case; close ours too so the next cycle reconnects instead of reusing a
434+
# dead handle forever.
435+
if self._schema_collector.connection_lost:
436+
self._reset_conn()
437+
438+
def _reset_conn(self) -> None:
439+
self._shutdown()

0 commit comments

Comments
 (0)