docs: add SEA telemetry integration design (PECO-3022)#455
Conversation
Closes the gap where the SEA (REST) transport emits zero client telemetry by introducing IStatementOperationObserver as the seam between statement classes and telemetry impl, refactoring ConnectionTelemetry.Create to drop its Thrift TSessionHandle coupling, and wiring observer callbacks at the SEA hookpoints. Co-authored-by: Isaac
Concentrates the swallow-all try/catch into one private helper rather than per-method boilerplate. Method bodies become one-line action delegates. Co-authored-by: Isaac
eric-wang-1990
left a comment
There was a problem hiding this comment.
Three inline comments on the observer contract — see lines 167, 171, 216.
| internal interface IStatementOperationObserver | ||
| { | ||
| // Contract: implementations MUST NOT throw. All methods are fail-open. | ||
| // Contract: methods may be called from any thread; implementations must be thread-safe. |
There was a problem hiding this comment.
Thread-safety contract contradicts the impl sketch.
This line says methods may be called from any thread; implementations must be thread-safe, but §11 then says StatementTelemetryContext is single-statement scope with reads only at OnFinalized time on the calling thread, and the §12 example uses plain _ctx.PollCount = count; — no Interlocked, no volatile, no lock. Plain scalar writes are not thread-safe in general (torn long writes on 32-bit, no happens-before with the finalize read).
The three sections don't reconcile. Pick one:
- Tighten the contract (recommended): callers must serialize calls per-statement; only
OnFinalizedmay race (between the error-path and the dispose-path) and is idempotent viaInterlocked.Exchangeon theemittedflag. This matches actual usage — a single statement is driven by oneExecuteQueryAsynccaller plus its reader's Dispose, and statements aren't shared. - Or make the writes actually thread-safe (Interlocked / volatile / lock), and accept the cost.
The second option is overkill for the actual usage. Recommend the first; the contract sentence here should change to call out that only OnFinalized is required to tolerate concurrent calls.
There was a problem hiding this comment.
Good catch — tightened the contract in §5.1 to match §11/§12 (commit 9f2c497).
Concretely:
- Lifecycle methods are NOT required to be thread-safe. The single statement is driven by one
ExecuteQueryAsynccaller plus its reader's Dispose, statements are not shared across threads, so plain scalar writes are correct. TheStatementTelemetryContextfield writes precedingOnFinalizedare happens-before the final read because they're serialized by the caller — noInterlocked/volatile/lockneeded. OnFinalizedis the one method that MUST be idempotent, since error-path and dispose-path can both reach it. Enforced viaInterlocked.Exchangeon a single_emittedflag; subsequent calls become no-ops.
Updated §5.1 interface-level comment, §11 Concurrency section, and the §15 test names (added OnFinalized_CalledFromErrorAndDispose_EnqueuesOnce to cover the documented race).
| // Contract: OnFinalized() is the terminal call; subsequent calls are no-ops. | ||
|
|
||
| void OnExecuteStarted(Statement.Types.Type stmtType, Operation.Types.Type opType, bool isCompressed); | ||
| void OnExecuteSucceeded(string statementId, ExecutionResult.Types.Format resultFormat); |
There was a problem hiding this comment.
OnExecuteSucceeded name and timing are misleading.
§6 says this fires "after response received" from ExecuteStatementAsync. But SEA's ExecuteStatementAsync returns a statementId with status commonly PENDING/RUNNING — the statement hasn't succeeded yet, just been accepted. Suggest renaming:
void OnStatementSubmitted(string statementId); // or OnExecuteAcceptedThis also has a knock-on effect on §8: the doc says SeaResultFormatMapper.Map(disposition, manifest, response) is called at OnExecuteSucceeded time, but the manifest is generally only fully populated when polling reaches SUCCEEDED — at the initial response it can be null. Either:
- Resolve result format at the terminal poll (then §8's table makes sense and the mapper has a real manifest to read), or
- Document explicitly that for SEA, format is resolved after
OnPollCompleted(status=SUCCEEDED), not on the initial response.
This may want a separate OnResultFormatResolved(format) callback, or fold the format into OnPollCompleted's args.
There was a problem hiding this comment.
Agreed on both points — renamed and moved format resolution (commit 9f2c497).
- Renamed
OnExecuteSucceeded→OnStatementSubmitted(string statementId). SEA's execute returnsPENDING/RUNNING, so the previous name was misleading. Added a short "naming rationale" note in §5.1 so future readers see why. - Moved result-format resolution to the terminal poll.
SeaResultFormatMapper.Map(disposition, manifest, response)now runs inPollUntilCompleteAsyncwhen status=SUCCEEDED(manifest is populated), and the format is folded into the existing terminal callback:OnPollCompleted(int count, long latencyMs, ExecutionResult.Types.Format resultFormat). Picked the fold over a separateOnResultFormatResolvedto keep the interface smaller — the manifest-populated moment IS the terminal poll, so one callback expresses both facts.
Updated: §4.1 class diagram, §5.1 interface, §6 integration-points table (renamed "Execute returned" → "Execute accepted"), §7 sequence diagram (with status=PENDING/RUNNING annotated and the mapper note at terminal poll), §8 mapper text, §12 example, §15 tests.
| | Execute returned | `StatementExecutionStatement.ExecuteQueryInternalAsync` — after response received | `OnExecuteSucceeded(statementId, resultFormat)` | | ||
| | Each poll iteration | `StatementExecutionStatement.PollUntilCompleteAsync` (line 453) — after each `_client.GetStatementAsync` | accumulate count/ms; emit `OnPollCompleted` once on terminal state | | ||
| | First batch ready | `StatementExecutionStatement.CreateCloudFetchReader` (line 542) for cloud-fetch path; `InlineArrowStreamReader` ctor for inline | `OnFirstBatchReady(latencyMs)` | | ||
| | Results consumed | reader Dispose (cloud-fetch reader or inline) | `OnConsumed(latencyMs)`; `OnChunksDownloaded(metrics)` for cloud-fetch | |
There was a problem hiding this comment.
How does the reader get the IStatementOperationObserver?
This row says OnConsumed / OnChunksDownloaded fire from reader Dispose, but the doc never specifies how CloudFetchReader / InlineArrowStreamReader obtain the observer. These readers are shared between Thrift and SEA today, so the wiring matters. Three options:
- Pass
_observerinto the reader constructor — couples shared readers to the observer interface. - Statement subscribes to a reader event/callback (
reader.OnDisposed += ...,reader.OnFirstBatch += ...) — lighter coupling, keeps the shared readers protocol-agnostic. - Wrap the reader in a thin decorator owned by
StatementExecutionStatementthat emits on Dispose — zero changes to shared reader classes.
Option 3 (or 2) seems preferable since the readers are shared infrastructure. Whatever the choice, please pick one and add it to §4.4 / §6 — without it the reviewer can't evaluate the blast radius of this design on the shared cloud-fetch code path.
There was a problem hiding this comment.
Picked option 3 (decorator) — documented in new §4.5 (commit 9f2c497).
Rationale: CloudFetchReader and InlineArrowStreamReader are shared with the Thrift path, so coupling them to the observer interface (option 1) would be invasive for shared infrastructure. Option 2 (reader-side events) still requires shared-reader changes. Option 3 keeps the shared readers completely unchanged.
StatementExecutionStatement wraps the underlying reader in a thin ObservingArrowReader : IArrowArrayStream decorator that it owns:
ReadNextRecordBatchAsyncfiresOnFirstBatchReadyon the first call (latency from decorator construction).DisposeAsyncfiresOnConsumed, andOnChunksDownloaded(_inner.GetChunkMetrics())when the inner is aCloudFetchReader.- Lifetime is bound to the
QueryResult.Streamreturned to the caller.
This also keeps Thrift and SEA symmetric — DatabricksStatement can use the same decorator if it wants to emit those callbacks through the observer interface.
The §6 integration-points table now reflects this (the "First batch ready" and "Results consumed" rows point at ObservingArrowReader), and §7's sequence diagram shows the wrap step explicitly. Added ObservingArrowReader_* unit tests to §15.
- Tighten thread-safety contract: lifecycle methods are single-threaded per-statement; only OnFinalized may race (error vs dispose) and must be idempotent. Aligns §5.1 with the §11/§12 impl sketch. - Rename OnExecuteSucceeded → OnStatementSubmitted; SEA execute returns PENDING/RUNNING, not succeeded. Move result-format resolution to the terminal poll (where the manifest is populated) and fold it into OnPollCompleted(count, latencyMs, resultFormat). - Add §4.5 documenting the ObservingArrowReader decorator that wires OnConsumed / OnChunksDownloaded / OnFirstBatchReady without modifying the shared CloudFetchReader / InlineArrowStreamReader. Co-authored-by: Isaac
Required by the repo's Apache RAT pre-commit hook. Co-authored-by: Isaac
🥞 Stacked PR
Use this link to review incremental changes.
Summary of the design
This design closes the gap where the SEA (REST) transport in the C# ADBC Databricks driver emits zero client telemetry — no session events, no per-statement operation events, no error events, no chunk metrics. SEA traffic is currently invisible to the
eng_lumberjackdriver-telemetry pipeline.The design:
IStatementOperationObserver) between statement classes and the telemetry implementation. The interface is shaped around the statement's lifecycle (OnExecuteStarted,OnPollCompleted, …) rather than telemetry's data model. Contract: fail-open — implementations must not throw.ConnectionTelemetry.Createto acceptstring sessionId(dropping its ThriftTSessionHandlecoupling) and aDriverMode modeparameter. Both transports use the same factory.StatementExecutionConnectionandStatementExecutionStatement.TelemetryClient,TelemetryClientManager,CircuitBreakerTelemetryExporter,DatabricksTelemetryExporter,FeatureFlagCache,TelemetrySessionContext,StatementTelemetryContext.Key decisions and alternatives considered
TelemetryHelper. This design supersedes that with an observer-shaped interface because: (a) it gives one-line callsites with no parameter threading, (b) it decouples statement classes from telemetry types so future tracing/audit observers can plug in without touching statement code, (c) it's trivially mockable for tests. The fail-open contract pushes all try/catch into the implementations exactly once — callsites stay clean.HiveServer2Connection's parent is in the Apache package we don't own), and interface + extension methods (functionally identical to the static helper). C# single inheritance + unowned Apache base classes make pure inheritance impractical.Createsignature rather than add overload. Changes the canonicalConnectionTelemetry.Createto takestring sessionId. Thrift converts at the call boundary (sessionHandle.SessionId.Guid.ToString()). Eliminates the Thrift leak from the telemetry API permanently.DriverMode.Seasetter. The cross-cutting Thrift gaps (workspace_id, auth_type, metadata-ops instrumentation, retry_count) are owned by the separate gap-fix workstream and will land independently. The only cross-cutting change pulled in here is unhooking the hardcodedDriverMode.Types.Type.ThriftinBuildDriverConnectionParams— without it SEA records would be silently mislabeled as Thrift.Areas needing specific review focus
§5.1) — the observer methods, naming, and the requirement that implementations never throw. Specifically: are the 8 methods the right cut, or is finer granularity (e.g. splitOnChunksDownloadedfromOnConsumed) preferred?ConnectionTelemetry.Createsignature change (§5.2) — replacesTSessionHandle?withstring sessionIdand adds aDriverMode modeparameter. This touches a stable API used by the existing Thrift path; the Thrift call site must convert at the boundary.§8) — SEA does not expose a typedResultFormat. The mapping table from wiredisposition× manifest state → protoExecutionResult.Formatis a judgment call; please review.§9,§16) — this design assumes theCloudFetchDownloader → ChunkMetrics → CloudFetchReader.GetChunkMetrics()plumbing from the gap-fix workstream lands first or concurrently. If gap-fix is delayed, SEA ships withChunkMetrics.Emptyand backfills later. Is that acceptable, or should we sequence differently?§17): polling-granularity semantics forPollCount,is_internal_callsemantics for SEAUSE SCHEMA, and whether SEA'soperation_typeshould always beEXECUTE_STATEMENT_ASYNCor map to sync-emulated variants.Related
csharp/doc/telemetry-design.mdTelemetryHelperstatic-helper proposal indocs/designs/fix-telemetry-gaps-design.mdfor the new SEA code; Thrift-side gap-fix work continues independentlyThis pull request and its description were written by Isaac.