Skip to content

Commit dd28a4e

Browse files
committed
fix: bind telemetry cleanup to the adapter invocation and claim queued sends
Repeated dbtRunner invokes reset the process-global invocation ID before leftover adapter cleanup, and close() can drop a POST_PARSE event that was already scheduled.
1 parent 3983c18 commit dd28a4e

4 files changed

Lines changed: 115 additions & 26 deletions

File tree

dbt/adapters/databricks/telemetry/coordinator.py

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,15 @@ def needs_post_parse(self, invocation_id: str) -> bool:
9191
return state is None or (not state.closed and state.post_parse is None)
9292

9393
def send_if_ready(self, invocation_id: str) -> None:
94-
# Keep connection-open off the network path.
94+
# Keep connection-open off the network path. Claim send inputs under
95+
# the lock so a later close() cannot cancel already-scheduled work.
9596
with self._lock:
96-
if not self._ready_to_send(self._states.get(invocation_id)):
97+
claimed = self._claim_send(self._states.get(invocation_id))
98+
if claimed is None:
9799
return
98100
thread = threading.Thread(
99-
target=self._drain,
100-
args=(invocation_id,),
101+
target=self._send_claimed,
102+
args=(invocation_id, *claimed),
101103
name="dbt-telemetry-send",
102104
daemon=True,
103105
)
@@ -114,6 +116,26 @@ def _ready_to_send(self, state: Optional[_InvocationState]) -> bool:
114116
return False
115117
return state.post_parse is not None and not state.post_parse_sent
116118

119+
def _claim_send(
120+
self, state: Optional[_InvocationState]
121+
) -> Optional[tuple[Optional[str], TelemetryLog, str, HeaderFactory, Optional[Any]]]:
122+
if not self._ready_to_send(state) or state is None or state.transport is None:
123+
return None
124+
if state.post_parse is None or state.transport.header_factory is None:
125+
return None
126+
transport = state.transport
127+
payload = state.post_parse
128+
event_id = state.post_parse_event_id
129+
state.post_parse_sent = True
130+
state.sending = True
131+
return (
132+
transport.host,
133+
payload,
134+
event_id,
135+
transport.header_factory,
136+
transport.workspace_id,
137+
)
138+
117139
def flush(self, timeout: Optional[float] = None) -> None:
118140
if timeout is None:
119141
timeout = float(client._TIMEOUT_SECONDS * 2)
@@ -129,26 +151,16 @@ def flush(self, timeout: Optional[float] = None) -> None:
129151
return
130152
pending[0].join(remaining)
131153

132-
def _drain(self, invocation_id: str) -> None:
133-
with self._lock:
134-
state = self._states.get(invocation_id)
135-
if state is None or state.closed or state.sending:
136-
return
137-
transport = state.transport
138-
if transport is None or transport.header_factory is None:
139-
return
140-
if state.post_parse is None or state.post_parse_sent:
141-
return
142-
payload = state.post_parse
143-
event_id = state.post_parse_event_id
144-
state.post_parse_sent = True
145-
state.sending = True
146-
host = transport.host
147-
header_factory = transport.header_factory
148-
workspace_id = transport.workspace_id
149-
154+
def _send_claimed(
155+
self,
156+
invocation_id: str,
157+
host: Optional[str],
158+
payload: TelemetryLog,
159+
event_id: str,
160+
header_factory: HeaderFactory,
161+
workspace_id: Optional[Any],
162+
) -> None:
150163
self._send(host, payload, event_id, header_factory, workspace_id)
151-
152164
with self._lock:
153165
state = self._states.get(invocation_id)
154166
if state is None or state.closed:

dbt/adapters/databricks/telemetry/hooks.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
)
99
from dbt.adapters.databricks.telemetry.coordinator import Transport, coordinator
1010

11+
# Bound at adapter init. dbt-core resets the process-global invocation ID
12+
# before leftover adapter cleanup, so teardown must not read the live ID.
13+
_INVOCATION_ID_ATTR = "_dbt_telemetry_invocation_id"
14+
1115

1216
def _current_invocation_id() -> Optional[str]:
1317
try:
@@ -19,6 +23,11 @@ def _current_invocation_id() -> Optional[str]:
1923
return None
2024

2125

26+
def _stored_invocation_id(adapter: Any) -> Optional[str]:
27+
invocation_id = getattr(adapter, _INVOCATION_ID_ATTR, None)
28+
return str(invocation_id) if invocation_id else None
29+
30+
2231
def on_adapter_init(adapter: Any) -> None:
2332
try:
2433
creds = getattr(getattr(adapter, "config", None), "credentials", None)
@@ -27,6 +36,7 @@ def on_adapter_init(adapter: Any) -> None:
2736
invocation_id = _current_invocation_id()
2837
if not invocation_id:
2938
return
39+
setattr(adapter, _INVOCATION_ID_ATTR, invocation_id)
3040
coordinator().mark_start(invocation_id)
3141
except Exception: # pragma: no cover - best-effort
3242
return
@@ -85,7 +95,7 @@ def on_run_end(adapter: Any) -> None:
8595
creds = getattr(config, "credentials", None)
8696
if not isinstance(creds, DatabricksCredentials) or not is_enabled_for_invocation(creds):
8797
return
88-
invocation_id = _current_invocation_id()
98+
invocation_id = _stored_invocation_id(adapter)
8999
if not invocation_id:
90100
return
91101
coordinator().close(invocation_id)

tests/unit/telemetry/test_coordinator.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,30 @@ def test_closed_invocation_rejects_late_callbacks(self, monkeypatch):
121121
c.set_transport("inv-1", _transport())
122122
c.flush()
123123
assert capture.calls == []
124+
125+
def test_close_does_not_cancel_already_queued_send(self, monkeypatch):
126+
pending = []
127+
128+
class DelayedThread:
129+
def __init__(self, target, args, name, daemon):
130+
self.target = target
131+
self.args = args
132+
133+
def is_alive(self):
134+
return False
135+
136+
def start(self):
137+
pending.append(self)
138+
139+
capture = _Capture()
140+
monkeypatch.setattr(coord_mod.threading, "Thread", DelayedThread)
141+
monkeypatch.setattr(coord_mod.client, "send", capture)
142+
c = coord_mod.Coordinator()
143+
c.set_post_parse("inv-1", _log())
144+
c.set_transport("inv-1", _transport())
145+
146+
assert len(pending) == 1
147+
c.close("inv-1")
148+
pending[0].target(*pending[0].args)
149+
150+
assert len(capture.calls) == 1

tests/unit/telemetry/test_hooks.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest.mock import Mock
33

44
from dbt.adapters.databricks.telemetry import hooks
5+
from dbt.adapters.databricks.telemetry.coordinator import Coordinator
56

67

78
def test_post_parse_is_not_rebuilt(monkeypatch):
@@ -24,12 +25,51 @@ def test_post_parse_is_not_rebuilt(monkeypatch):
2425
def test_run_end_closes_without_waiting(monkeypatch):
2526
coord = Mock()
2627
monkeypatch.setattr(hooks, "coordinator", lambda: coord)
27-
monkeypatch.setattr(hooks, "_current_invocation_id", lambda: "inv-1")
2828
monkeypatch.setattr(hooks, "DatabricksCredentials", object)
2929
monkeypatch.setattr(hooks, "is_enabled_for_invocation", lambda _: True)
30-
adapter = SimpleNamespace(config=SimpleNamespace(credentials=SimpleNamespace()))
30+
adapter = SimpleNamespace(
31+
config=SimpleNamespace(credentials=SimpleNamespace()),
32+
_dbt_telemetry_invocation_id="inv-1",
33+
)
3134

3235
hooks.on_run_end(adapter)
3336

3437
coord.flush.assert_not_called()
3538
coord.close.assert_called_once_with("inv-1")
39+
40+
41+
def test_run_end_closes_stored_invocation_not_current_global(monkeypatch):
42+
coord = Mock()
43+
monkeypatch.setattr(hooks, "coordinator", lambda: coord)
44+
monkeypatch.setattr(hooks, "_current_invocation_id", lambda: "inv-2")
45+
monkeypatch.setattr(hooks, "DatabricksCredentials", object)
46+
monkeypatch.setattr(hooks, "is_enabled_for_invocation", lambda _: True)
47+
adapter = SimpleNamespace(
48+
config=SimpleNamespace(credentials=SimpleNamespace()),
49+
_dbt_telemetry_invocation_id="inv-1",
50+
)
51+
52+
hooks.on_run_end(adapter)
53+
54+
coord.close.assert_called_once_with("inv-1")
55+
56+
57+
def test_stale_cleanup_does_not_tombstone_next_invocation(monkeypatch):
58+
coord = Coordinator()
59+
current = ["inv-1"]
60+
monkeypatch.setattr(hooks, "coordinator", lambda: coord)
61+
monkeypatch.setattr(hooks, "DatabricksCredentials", object)
62+
monkeypatch.setattr(hooks, "is_enabled_for_invocation", lambda _: True)
63+
monkeypatch.setattr(hooks, "_current_invocation_id", lambda: current[0])
64+
leftover = SimpleNamespace(config=SimpleNamespace(credentials=object()))
65+
66+
hooks.on_adapter_init(leftover)
67+
hooks.on_run_end(leftover)
68+
current[0] = "inv-2"
69+
hooks.on_run_end(leftover)
70+
next_adapter = SimpleNamespace(config=SimpleNamespace(credentials=object()))
71+
hooks.on_adapter_init(next_adapter)
72+
73+
assert leftover._dbt_telemetry_invocation_id == "inv-1"
74+
assert next_adapter._dbt_telemetry_invocation_id == "inv-2"
75+
assert coord.needs_post_parse("inv-2") is True

0 commit comments

Comments
 (0)