sap_hana: run schema collection in a DBMAsyncJob background thread - #24128
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: 86b6d6b | Docs | Datadog PR Page | Give us feedback! |
e3a6d31 to
6672354
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6af5e12 to
5fcbb7e
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3495479 to
ec2ff5d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ec2ff5d410
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
5fcbb7e to
3495479
Compare
| # dedicated HANA connection) are released when the check is unscheduled (e.g. | ||
| # cluster-agent flavor or one-off check invocations), instead of leaking the | ||
| # DBMAsyncJob thread pool. | ||
| self._schema_collection_job.cancel() |
There was a problem hiding this comment.
(and the schema job's dedicated HANA connection)
Just a note that i don't believe this is true from what I see here today. Sharing this extra info for your context and misleading AI comment but I'm not sure there's much to act on today. Calling DBMAsyncJob.cancel() only sets a cancel event on the ThreadpoolExecutor job, there's currently no builtin way to close connections on cancel automatically. It's a bit misleading but shutdown_callback only fires on job loop failures to cleanly shutdown the job loop, but the way we wire this up it gets recreated the next call to AgentCheck.check()
There will be a cleaner interface to work with these soon as I recently merged this into datadog_checks_base but we haven't released a new version of that package to be consumed by integrations yet. What the linked PR doesn't demonstrate though and worth being aware of is that AgentCheck.check() and AgentCheck.cancel() can get called concurrently and interactions between them need to be thread safe. IE, we had to put mutex's in Postgres to guard closing a connection from cancel() which could still be in use for an in-flight query on an async job thread (because the GIL is yielded on IO) so a segfault would occur when the query returned to a null connection object
There was a problem hiding this comment.
Although realistically since schema collection defaults to 10 min intervals, there's probably little need for a persistent connection here. It might be simpler to open/close each collection cycle. We don't do this in DBM integrations because we typically already have connections open to the DB in thread safe pools when using long held connections.
It's unknown to me how saphana connection life cycles work and whether a 10 min idle connection will always terminate itself or if there are keep alives, etc.
eric-weaver
left a comment
There was a problem hiding this comment.
Implementation of the DBMAsyncJob looks good from DBM
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>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 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>
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>
27348aa to
3204b90
Compare
Review from maycmlee is dismissed. Related teams and files:
- documentation
- sap_hana/assets/configuration/spec.yaml
HadhemiDD
left a comment
There was a problem hiding this comment.
sap_hana/benchmarks/schema_collection_memory/run_collector.py:68
Request: This caller was missed by the rename — SapHanaCheck._schema_collector no longer exists, so the benchmark dies with AttributeError before collecting anything. It needs check._schema_collection_job._schema_collector.collect_schemas(). Worth fixing rather than leaving: this is the benchmark the PAYLOAD_COLUMN_CHUNK_SIZE comment in schemas.py:62-64 cites as justification for the chunking constant.
| super().__init__( | ||
| check, | ||
| config_host=check._server, | ||
| rate_limit=1.0 / collection_interval, |
There was a problem hiding this comment.
Note: this is a very edge use case where a customer would try unconventional fractional value for the collection_interval.
A fractional collection_interval below 1 now takes down the entire check. int(config.collection_interval or 600) truncates 0.5 to 0, and rate_limit=1.0 / collection_interval then raises ZeroDivisionError inside __init__. Since SapHanaCheck.__init__ constructs this job unconditionally (sap_hana.py:96-97) without consulting enabled, the whole integration fails to instantiate and emits no metrics at all — backups, license, memory, I/O — even for someone who never turned schema collection on.
The sibling job already avoids the truncation by not casting (data_observability.py:68,72 uses 1 / float(collection_interval)). I'd match it and clamp:
collection_interval = float(config.collection_interval or 600)
if collection_interval <= 0:
collection_interval = 600
...
rate_limit=1.0 / collection_interval,
(Note 0 itself is falsy and falls through to 600, so only sub-1 floats hit the crash.)
There was a problem hiding this comment.
Powered by Claude
Fixed in 86b6d6b. Matched the sibling job by casting to float instead of int and clamping non-positive values back to the default, so a sub-1 interval no longer truncates to 0 and crashes __init__ via the rate_limit division. Applied the same clamp in HanaSchemaCollectorConfig so it also can't ship as collection_interval: 0 in the payload, and added a regression test. Agreed it's an edge case, but a whole-check crash warranted the guard.
| try: | ||
| conn = self._get_conn() | ||
| self._schema_collector._conn = conn | ||
| self._schema_collector.collect_schemas() |
There was a problem hiding this comment.
Powred by Claude
The job runs on its own connection, but the payload builder still reaches back into the main check's connection from this thread. SchemaCollector.base_event includes "dbms_version": str(self._check.dbms_version) and is re-read on every maybe_flush, and SapHanaCheck.dbms_version (sap_hana.py:220-230) runs SELECT VERSION FROM SYS.M_DATABASE on self._conn — the connection the main check loop is concurrently using via iter_rows, with no lock.
The two config paths fail differently, and both are bad:
- With persist_db_connections: false, check()'s finally closes and nulls self._conn at the end of every run (sap_hana.py:139-151). For almost the entire window the job is running, the guard on line 222 is false, so _dbms_version is never set and every schema payload ships dbms_version: "unknown" — and because nothing is cached, the query is re-attempted on every single flush.
- With the default persist_db_connections: true, the main thread can close and null _conn while this thread is inside the property. The resulting error is swallowed by except Exception on line 228 and 'unknown' is cached permanently — the guard only re-attempts while _dbms_version is None — so one transient race poisons dbms_version for the life of the process.
Postgres avoids exactly this by routing job queries through a pool whose accessor is documented as threadsafe (postgres/datadog_checks/postgres/postgres.py:1141-1153); SAP HANA has no equivalent. Since the job already owns a dedicated connection, I'd resolve the version on it — e.g. extract a resolve_dbms_version(conn) on the check and prime it from run_job before collecting.
There was a problem hiding this comment.
Powered by Claude
Fixed in 86b6d6b. Extracted _resolve_dbms_version(conn) on the check and prime it from run_job on the job's dedicated connection, exactly as suggested. The dbms_version property now only returns the cached value — it no longer queries self._conn off-thread — so the race with the main check loop is gone. A transient failure leaves the value unresolved (rather than caching 'unknown') so the next cycle retries.
| self._conn: Any = None # injected by HanaSchemaCollectionJob; falls back to check._conn | ||
|
|
||
| def _active_conn(self) -> Any: | ||
| return self._conn if self._conn is not None else self._check._conn |
There was a problem hiding this comment.
Powered by Claude
The job and the collector coordinate by writing and reading each other's private attributes, and _conn is overloaded to carry two unrelated meanings: "which connection to use" and "the connection died." run_job() writes self._schema_collector._conn = conn (line 409), then reads that same private field back as an error sentinel (line 418); _get_cursor() and _get_databases() null it out to signal upward (lines 307, 287); and in the other direction _active_conn() reads self._check._conn. Two objects mutating each other's underscore-prefixed state is the kind of coupling that breaks silently later — any future change to how the collector caches its connection also breaks the job's failure detection, with no signature to guide the change.
The check._conn fallback on line 245 compounds it. It has no non-test caller: run_job() always assigns _conn before every collect_schemas() call, so in production the else branch is dead. What it does do is guarantee that if the job ever forgets to inject a connection, the multi-second catalog query silently runs on the main check's connection from this thread — precisely what this PR exists to stop — rather than failing loudly. It can also hand back None, producing an AttributeError swallowed as "Could not determine current HANA database."
I'd make the contract explicit and drop the fallback:
def set_connection(self, conn: Any) -> None:
self._conn = conn
self.connection_lost = False
# in _get_cursor / _get_databases on HanaError: self.connection_lost = True
Then run_job() checks if self._schema_collector.connection_lost: and _active_conn() collapses to self._conn. Same behaviour, named contract.
There was a problem hiding this comment.
Powered by Claude
Good catch — fixed in 86b6d6b. Replaced the overloaded _conn with the named contract you suggested: set_connection(conn) injects the job's connection and clears the flag, _get_cursor/_get_databases set connection_lost = True on HanaError, and run_job checks if self._schema_collector.connection_lost. Also dropped the dead check._conn fallback so the catalog query can never silently run on the main check's connection.
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>
Validation ReportAll 21 validations passed. Show details
|
What does this PR do?
Moves SAP HANA schema collection out of the main check loop and into a
DBMAsyncJobbackground thread, using the same pattern already in place for the Data Observability job.Key changes:
HanaSchemaCollectionJob(DBMAsyncJob)class inschemas.py— opens its own dedicatedhdbcliconnection (via_get_connection_properties()), runsHanaSchemaCollector.collect_schemas()in a background thread at the configuredcollection_intervalHanaSchemaCollectorgains a_connattribute +_active_conn()helper; when the job injects its connection, the collector uses it; otherwise falls back tocheck._conn(backward-compatible for unit tests)sap_hana.py: replaces_maybe_collect_schemas()+ three sync state variables with_schema_collection_job.run_job_loop(tags)— no more blocking the main check loopSapHanaCheck.cancel()now stops the schema collection job alongside the Data Observability job, so its background thread and dedicated HANA connection are released promptly on teardown (unschedule / one-off runs) instead of lingering until the inactivity timeoutSchemaCollector.collect_schemas()swallows per-database HANA errors, a transient disconnect never surfaced to the job and the dead connection was reused every cycle. The collector now drops its connection reference onHanaErrorand the job closes/resets it so the next cycle reconnectsspec.yaml/config_models: adds a hiddenrun_syncflag to thecollect_schemasblock_schema_collection_jobdirectly; four sync scheduling tests replaced with five job-level tests (run, disabled, connection reset on raised error, connection reset on swallowed error, and cancellation)Motivation
Schema collection runs a catalog-wide SQL query that can take seconds on large HANA tenants. Running it synchronously blocked backup, license, memory, and I/O metrics on every affected check run. Moving it to a
DBMAsyncJobbackground thread (same approach as PostgresPostgresMetadataand the SAP HANA DO job) eliminates the blocking and lets the main check loop complete on time.Review checklist (to be filled by reviewers)
qa/requiredif this PR needs QA validation, orqa/skip-qaif it does not. Exactly one of the two is required.backport/<branch-name>label to the PR and it will automatically open a backport PR once this one is merged