Skip to content

Commit f9c243d

Browse files
committed
fix: keep pool slots, test transactions and shared clocks correct after timeouts
A connection that can neither be recovered nor replaced after a timeout is now returned to its pool marked broken, so later work fails with the driver's error instead of waiting forever for a lost slot; each later return retries the replacement, and microbatch subworker slots are always released. A PostgreSQL unit test claims its transaction only after BEGIN succeeds, so a refused BEGIN can no longer make a later release roll back a caller's transaction. A queued BigQuery job pauses an item's clock only while every statement under that deadline is queued, so one queued microbatch worker no longer pauses a model whose other workers are running.
1 parent f2ed3f3 commit f9c243d

14 files changed

Lines changed: 333 additions & 18 deletions

File tree

‎src/sqlbuild/adapters/bigquery/classes/bigquery_connection.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,13 @@ def _wait_while_pending(*, job: Any, deadline: ItemDeadline | None) -> None:
9999

100100
if deadline is None or getattr(job, "state", None) != PENDING_JOB_STATE:
101101
return
102-
deadline.hold()
102+
deadline.statement_queued()
103103
try:
104104
while getattr(job, "state", None) == PENDING_JOB_STATE:
105105
time.sleep(PENDING_JOB_POLL_SECONDS)
106106
job.reload()
107107
finally:
108-
deadline.release()
108+
deadline.statement_dequeued()
109109

110110

111111
def _job_id(*, job: Any) -> str | None:

‎src/sqlbuild/adapters/postgres/classes/postgres_adapter.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ def get_columns_for_relations(
148148
def create_sql_test_output(self, *, connection: Any, sql: str, name: str) -> str | None:
149149
"""Store a unit test's output in a temporary table of one transaction, for poolers."""
150150

151-
if connection.claim_sql_test_transaction():
151+
if connection.needs_sql_test_transaction():
152152
_ = self.execute(connection=connection, sql="BEGIN")
153+
connection.claim_sql_test_transaction()
153154
try:
154155
_ = self.execute(
155156
connection=connection,

‎src/sqlbuild/adapters/postgres/classes/postgres_connection.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,15 @@ def _apply_statement_timeout(self) -> None:
5656
self._local_timeout_set = True
5757
self._local_timeout_deadline = id(deadline)
5858

59-
def claim_sql_test_transaction(self) -> bool:
60-
"""Whether a unit test must open its own transaction, which it then owns."""
59+
def needs_sql_test_transaction(self) -> bool:
60+
"""Whether a unit test must open its own transaction before storing outputs."""
61+
62+
return not self._sql_test_transaction and not self.in_transaction()
63+
64+
def claim_sql_test_transaction(self) -> None:
65+
"""Record that the transaction a unit test just opened is its own to end."""
6166

62-
if self._sql_test_transaction or self.in_transaction():
63-
return False
6467
self._sql_test_transaction = True
65-
return True
6668

6769
def release_sql_test_transaction(self) -> bool:
6870
"""Whether a unit test owned the open transaction, which it must now end."""

‎src/sqlbuild/executor/build/classes/build_scheduler.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -680,8 +680,10 @@ def _run_microbatch_batch_worker(
680680
try:
681681
return execute(batch, connection)
682682
finally:
683-
self._connection_pool.put(connection)
684-
self._release_microbatch_subworker()
683+
try:
684+
self._connection_pool.put(connection)
685+
finally:
686+
self._release_microbatch_subworker()
685687

686688
def _reserve_microbatch_subworker(self) -> bool:
687689
with self._microbatch_coordinator_lock:

‎src/sqlbuild/executor/scheduling/classes/recovering_connection_pool.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@ def __init__(self, *, recovery: ConnectionRecovery) -> None:
1616
self._recovery: ConnectionRecovery = recovery
1717

1818
def put(self, item: Any, block: bool = True, timeout: float | None = None) -> None:
19-
super().put(self._recovery.usable(item), block=block, timeout=timeout)
19+
returned: Any = item
20+
try:
21+
returned = self._recovery.usable(item)
22+
finally:
23+
super().put(returned, block=block, timeout=timeout)

‎src/sqlbuild/runtime/execution_limits/classes/connection_recovery.py‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,41 @@ def __init__(self, *, adapter: Any, connect: Callable[[], Any]) -> None:
2323
self._adapter: Any = adapter
2424
self._connect: Callable[[], Any] = connect
2525
self._replacements: list[Any] = []
26+
self._broken: set[int] = set()
2627
self._lock: threading.Lock = threading.Lock()
2728

2829
def usable(self, connection: Any) -> Any:
29-
"""`connection` when it is healthy, otherwise a newly opened replacement."""
30+
"""`connection` if healthy, else a replacement, else the original kept marked broken."""
3031

31-
if not interrupted_connections.take(connection):
32+
interrupted: bool = interrupted_connections.take(connection)
33+
with self._lock:
34+
broken: bool = id(connection) in self._broken
35+
if not interrupted and not broken:
3236
return connection
3337
with execution_deadlines_suspended():
3438
if self._responds(connection):
39+
self._forget_broken(connection)
3540
return connection
3641
try:
3742
self._adapter.rollback(connection)
3843
except Exception as error:
3944
_LOGGER.debug("rollback after an interrupted statement failed: %s", error)
4045
if self._responds(connection):
46+
self._forget_broken(connection)
4147
return connection
48+
try:
49+
replacement: Any = self._connect()
50+
except Exception as error:
51+
with self._lock:
52+
self._broken.add(id(connection))
53+
_LOGGER.warning(
54+
"A connection left unusable by a timed-out statement could not be replaced (%s); "
55+
"work that uses it will fail",
56+
error,
57+
)
58+
return connection
4259
_LOGGER.warning("A connection left unusable by a timed-out statement was replaced")
43-
replacement: Any = self._connect()
60+
self._forget_broken(connection)
4461
with self._lock:
4562
self._replacements.append(replacement)
4663
return replacement
@@ -57,6 +74,10 @@ def close_replacements(self) -> None:
5774
except Exception as error:
5875
_LOGGER.debug("closing a replacement connection failed: %s", error)
5976

77+
def _forget_broken(self, connection: Any) -> None:
78+
with self._lock:
79+
self._broken.discard(id(connection))
80+
6081
def _responds(self, connection: Any) -> bool:
6182
try:
6283
_ = self._adapter.execute(connection=connection, sql=CONNECTION_PROBE_SQL)

‎src/sqlbuild/runtime/execution_limits/classes/item_deadline.py‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def __init__(
4646
self._running_since: float | None = None
4747
self._clock_started: bool = False
4848
self._holds: int = 0
49+
self._queued_statements: int = 0
4950
self._closed: bool = False
5051
self._active_statements: int = 0
5152
self._awaiting_confirmation: bool = False
@@ -102,6 +103,7 @@ def statement_finished(self, *, query_id: str | None) -> None:
102103
deadline._active_statements = max(0, deadline._active_statements - 1)
103104
if query_id:
104105
deadline._query_ids.append(query_id)
106+
deadline._update_clock()
105107

106108
def statement_submitted(self, *, query_id: str | None) -> None:
107109
if not query_id:
@@ -111,18 +113,32 @@ def statement_submitted(self, *, query_id: str | None) -> None:
111113
deadline._query_ids.append(query_id)
112114

113115
def hold(self) -> None:
114-
"""Stop the clock, for warehouse queueing or cleanup that must not count."""
116+
"""Stop the clock for cleanup while no statement runs under this deadline."""
115117

116118
for deadline in self.chain():
117119
with deadline._lock:
118120
deadline._holds += 1
119-
deadline._pause_clock()
121+
deadline._update_clock()
120122

121123
def release(self) -> None:
122124
for deadline in self.chain():
123125
with deadline._lock:
124126
deadline._holds = max(0, deadline._holds - 1)
125-
deadline._resume_clock()
127+
deadline._update_clock()
128+
129+
def statement_queued(self) -> None:
130+
"""Mark a running statement as waiting in a warehouse queue; only queued work pauses."""
131+
132+
for deadline in self.chain():
133+
with deadline._lock:
134+
deadline._queued_statements += 1
135+
deadline._update_clock()
136+
137+
def statement_dequeued(self) -> None:
138+
for deadline in self.chain():
139+
with deadline._lock:
140+
deadline._queued_statements = max(0, deadline._queued_statements - 1)
141+
deadline._update_clock()
126142

127143
def close(self) -> None:
128144
"""Stop the clock and the watchdog when the item's scope ends."""
@@ -198,6 +214,7 @@ def _start_statement(self) -> None:
198214
with self._lock:
199215
self._active_statements += 1
200216
if self._clock_started or self._closed:
217+
self._update_clock()
201218
return
202219
self._clock_started = True
203220
self._resume_clock()
@@ -212,9 +229,21 @@ def _pause_clock(self) -> None:
212229
self._running_since = None
213230

214231
def _resume_clock(self) -> None:
215-
if self._clock_started and not self._closed and not self._holds:
232+
if self._clock_started and not self._closed and not self._paused():
216233
self._running_since = self._running_since or time.monotonic()
217234

235+
def _paused(self) -> bool:
236+
"""Paused while held or queued, unless another statement is running meanwhile."""
237+
238+
held: bool = bool(self._holds or self._queued_statements)
239+
return held and self._active_statements <= self._queued_statements
240+
241+
def _update_clock(self) -> None:
242+
if self._paused():
243+
self._pause_clock()
244+
else:
245+
self._resume_clock()
246+
218247
def _watch(self) -> None:
219248
while True:
220249
remaining: float = self._own_remaining(grace=self._grace)

‎tests/unit/src/sqlbuild/adapters/postgres/_test_types.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,12 @@ class FailedSqlTestOutputTestCase:
152152
description: str
153153
expected_statements: tuple[str, ...]
154154
expected_in_transaction: bool
155+
156+
157+
@dataclass(frozen=True)
158+
class RefusedBeginTestCase:
159+
"""A unit test whose BEGIN the deadline refuses, followed by work in a caller transaction."""
160+
161+
description: str
162+
expected_statements: tuple[str, ...]
163+
expected_in_transaction: bool

‎tests/unit/src/sqlbuild/adapters/postgres/test_deadline_transactions.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from tests.unit.src.sqlbuild.adapters.postgres._test_types import (
2121
DeadlineTransactionTestCase,
2222
FailedSqlTestOutputTestCase,
23+
RefusedBeginTestCase,
2324
SqlTestOutputTransactionTestCase,
2425
)
2526

@@ -154,5 +155,39 @@ def test_given_failing_output_query_when_storing_then_transaction_is_rolled_back
154155
assert connection.in_transaction() is test_case.expected_in_transaction
155156

156157

158+
@pytest.mark.parametrize(
159+
"test_case",
160+
[
161+
RefusedBeginTestCase(
162+
description="a refused BEGIN leaves no ownership that could end a caller transaction",
163+
expected_statements=("BEGIN", f"DROP TABLE IF EXISTS {_OUTPUT}"),
164+
expected_in_transaction=True,
165+
)
166+
],
167+
ids=lambda case: case.description,
168+
)
169+
def test_given_refused_begin_when_storing_output_then_later_release_keeps_caller_transaction(
170+
test_case: RefusedBeginTestCase,
171+
) -> None:
172+
adapter: PostgresAdapter = PostgresAdapter()
173+
raw: CancellableRawConnection = CancellableRawConnection()
174+
connection: _PostgresConnection = _PostgresConnection(raw)
175+
176+
with item_deadline_scope(
177+
kind=ItemKind.TEST, name="orders", policy=_POLICY, adapter=adapter, connection=connection
178+
) as deadline:
179+
assert deadline is not None
180+
deadline.expire()
181+
with pytest.raises(ItemTimeoutError):
182+
_ = adapter.create_sql_test_output(
183+
connection=connection, sql="SELECT 1 AS order_id", name=_OUTPUT
184+
)
185+
_ = adapter.execute(connection=connection, sql="BEGIN")
186+
adapter.drop_sql_test_output(connection=connection, relation=_OUTPUT)
187+
188+
assert without_limits(raw.executed) == test_case.expected_statements
189+
assert connection.in_transaction() is test_case.expected_in_transaction
190+
191+
157192
if __name__ == "__main__":
158193
pytest.main([__file__, "-vv"])
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass(frozen=True)
5+
class UnreplaceableConnectionTestCase:
6+
"""A timed-out item whose connection can neither be recovered nor replaced."""
7+
8+
description: str
9+
later_items: int
10+
expected_completions: tuple[tuple[str, str], ...]
11+
expected_reconnect_attempts: int
12+
expected_maximum_seconds: float

0 commit comments

Comments
 (0)