Skip to content

Commit e2ae326

Browse files
committed
feat(loads): retry table loads while another load holds the table
The server serialises loads per table and answers a second writer with HTTP 409 RESOURCE_LOCKED + Retry-After. Add hotdata/loads.py with DatabasesApi / ConnectionsApi subclasses that retry load_database_table and load_managed_table on that code only, honouring Retry-After with jitter under a LoadRetryPolicy budget (60s default), and raise TableLockedError when it is exhausted. CONFLICT and IDEMPOTENCY_KEY_CONFLICT are never retried; the code is matched, not the message. Export them as hotdata.DatabasesApi / ConnectionsApi via patch_query_exports.py so a regen keeps the swap, and list loads.py in .openapi-generator-ignore.
1 parent e6f00ff commit e2ae326

7 files changed

Lines changed: 593 additions & 10 deletions

File tree

‎.openapi-generator-ignore‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ setup.py
1111
# connection-reset retry on all methods, #118), uploads.py (transparent
1212
# presigned direct-to-storage upload flow), _useragent.py (runtime-resolved
1313
# User-Agent; the generator's httpUserAgent property can only bake a literal at
14-
# generation time, which goes stale between releases).
14+
# generation time, which goes stale between releases), loads.py (409
15+
# RESOURCE_LOCKED retry of table loads while another load holds the table).
1516
hotdata/arrow.py
1617
hotdata/query.py
1718
hotdata/_retry.py
1819
hotdata/uploads.py
20+
hotdata/loads.py
1921
hotdata/_useragent.py
2022

2123
# Hand-written test for the patched ApiClient.close()/context-manager behavior

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `DatabasesApi.load_database_table` and `ConnectionsApi.load_managed_table`
13+
(the classes exported from `hotdata`) retry automatically while another load
14+
holds the table: HTTP 409 with error code `RESOURCE_LOCKED` is retried,
15+
honouring `Retry-After` (else exponential backoff) with jitter, under a
16+
60s budget by default. Tune or disable with `LoadRetryPolicy`
17+
(`DatabasesApi(client, load_retry=LoadRetryPolicy(...))`); when the budget
18+
runs out, `hotdata.TableLockedError` (a 409 `ApiException`) is raised. Other
19+
409s (`CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`) are never retried. The raw
20+
generated classes stay available under `hotdata.api.*`.
21+
1022
### Changed
1123

1224
- feat(query): add arrow ipc stream response format

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,25 @@ with hotdata.ApiClient(configuration) as api_client:
6060

6161
Each `Api` class groups endpoints by resource. Construct the client, then call the typed methods you need.
6262

63+
## Loading tables
64+
65+
Loads are serialised per table on the server. `hotdata.DatabasesApi` and
66+
`hotdata.ConnectionsApi` retry a load that hits a table another load still
67+
holds (HTTP 409, error code `RESOURCE_LOCKED`), honouring the server's
68+
`Retry-After`, for up to 60s by default:
69+
70+
```python
71+
from hotdata import DatabasesApi, LoadRetryPolicy, TableLockedError
72+
73+
databases = DatabasesApi(api_client, load_retry=LoadRetryPolicy(deadline_s=120))
74+
try:
75+
databases.load_database_table(db_id, "public", "events", request)
76+
except TableLockedError as e:
77+
print(f"gave up after {e.attempts} attempts: {e.reason}")
78+
```
79+
80+
Other 409s (`CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`) are raised immediately.
81+
6382
## Arrow results
6483

6584
Query results can be fetched as an [Apache Arrow](https://arrow.apache.org/) IPC stream instead of JSON, which is faster and far more memory-efficient for large result sets. Install the optional extra:

‎hotdata/__init__.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,10 +312,18 @@
312312
# hotdata.query.QueryApi adds 429 retry + truncation auto-follow;
313313
# hotdata.arrow.ResultsApi adds Arrow IPC result fetch;
314314
# hotdata.uploads.UploadsApi adds transparent presigned direct-to-storage
315-
# uploads. The raw generated classes remain importable from hotdata.api.*.
315+
# uploads; hotdata.loads.DatabasesApi / ConnectionsApi retry table loads
316+
# while another load holds the table (409 RESOURCE_LOCKED). The raw
317+
# generated classes remain importable from hotdata.api.*.
316318
from hotdata.query import QueryApi as QueryApi # noqa: E402,F811
317319
from hotdata.arrow import ResultsApi as ResultsApi # noqa: E402,F811
318320
from hotdata.uploads import UploadsApi as UploadsApi # noqa: E402,F811
321+
from hotdata.loads import DatabasesApi as DatabasesApi # noqa: E402,F811
322+
from hotdata.loads import ConnectionsApi as ConnectionsApi # noqa: E402,F811
323+
from hotdata.loads import ( # noqa: E402,F401
324+
LoadRetryPolicy as LoadRetryPolicy,
325+
TableLockedError as TableLockedError,
326+
)
319327
# The upload error hierarchy + option/helper types, so `from hotdata import
320328
# UploadError` / `StorageError` / ... works (they otherwise live only under
321329
# hotdata.uploads and are invisible to autocomplete on `hotdata.`).
@@ -350,4 +358,6 @@
350358
"UploadProgress",
351359
"UploadSource",
352360
"tqdm_progress",
361+
"LoadRetryPolicy",
362+
"TableLockedError",
353363
]

‎hotdata/loads.py‎

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
"""Retry of table loads that collide with another load on the same table.
2+
3+
The server serialises loads per table. While one load holds a table, a second
4+
``POST .../loads`` against the same table is rejected with HTTP 409, error code
5+
``RESOURCE_LOCKED`` and a ``Retry-After`` header::
6+
7+
HTTP/1.1 409 Conflict
8+
Retry-After: 5
9+
10+
{"error": {"code": "RESOURCE_LOCKED",
11+
"message": "another operation is already running for ...; retry shortly"}}
12+
13+
The generated clients raise :class:`~hotdata.exceptions.ConflictException` and
14+
leave the retry to the caller. This module wraps the load methods so that case
15+
is retried automatically, mirroring the 429 handling in :mod:`hotdata.query`:
16+
17+
* only a 409 whose error **code** is ``RESOURCE_LOCKED`` is retried. Every
18+
other 409 (``CONFLICT``, ``IDEMPOTENCY_KEY_CONFLICT``, a load against an
19+
upload that is already being loaded, ...) is raised unchanged — those are
20+
not transient;
21+
* ``Retry-After`` is honoured when present, otherwise bounded exponential
22+
backoff; jitter is always added so waiting writers do not stampede the
23+
freed lock;
24+
* the whole wait is capped by :attr:`LoadRetryPolicy.deadline_s` (60s by
25+
default). Past it, or past ``max_retries``, :class:`TableLockedError` is
26+
raised with the last server response attached.
27+
28+
Two generated classes carry a ``/loads`` operation and get a drop-in subclass
29+
here: :class:`DatabasesApi` (``load_database_table``) and
30+
:class:`ConnectionsApi` (``load_managed_table``). Only the plain methods
31+
retry; the generated ``*_with_http_info`` / ``*_without_preload_content``
32+
variants are left untouched. ``from hotdata import DatabasesApi`` /
33+
``ConnectionsApi`` resolves to these classes; the raw generated ones remain
34+
importable from ``hotdata.api.*``.
35+
"""
36+
37+
from __future__ import annotations
38+
39+
import json
40+
import logging
41+
import random
42+
import time
43+
from dataclasses import dataclass
44+
from typing import Any, Callable, Optional, TypeVar
45+
46+
from hotdata.api.connections_api import ConnectionsApi as _GeneratedConnectionsApi
47+
from hotdata.api.databases_api import DatabasesApi as _GeneratedDatabasesApi
48+
from hotdata.exceptions import ApiException
49+
from hotdata.query import _parse_retry_after
50+
51+
_HTTP_CONFLICT = 409
52+
RESOURCE_LOCKED_ERROR_CODE = "RESOURCE_LOCKED"
53+
54+
_log = logging.getLogger("hotdata.loads")
55+
56+
_T = TypeVar("_T")
57+
58+
59+
@dataclass(frozen=True)
60+
class LoadRetryPolicy:
61+
"""Controls 409 (``RESOURCE_LOCKED``) retry of table loads.
62+
63+
:param max_retries: Maximum retry attempts after the initial request.
64+
:param base_backoff_s: Base delay for exponential backoff when the server
65+
sends no ``Retry-After`` header.
66+
:param max_backoff_s: Cap on any single computed backoff delay. A
67+
``Retry-After`` larger than the cap is still honoured.
68+
:param deadline_s: Overall budget across all attempts; once exceeded (or a
69+
computed delay would push past it) retries stop.
70+
:param jitter: Fraction of the delay added as random jitter, so several
71+
writers waiting on the same table do not retry in lockstep.
72+
"""
73+
74+
max_retries: int = 10
75+
base_backoff_s: float = 1.0
76+
max_backoff_s: float = 15.0
77+
deadline_s: float = 60.0
78+
jitter: float = 0.5
79+
80+
81+
class TableLockedError(ApiException):
82+
"""Raised when a load keeps getting HTTP 409 ``RESOURCE_LOCKED`` (another
83+
load holds the table) and the retry budget or attempt count is exhausted.
84+
85+
``attempts`` records how many requests were made; ``body`` / ``headers`` /
86+
``data`` carry the last server response.
87+
"""
88+
89+
def __init__(self, *, attempts: int, reason: str, last_exc: ApiException) -> None:
90+
self.attempts = attempts
91+
super().__init__(status=_HTTP_CONFLICT, reason=reason)
92+
self.body = getattr(last_exc, "body", None)
93+
self.data = getattr(last_exc, "data", None)
94+
self.headers = getattr(last_exc, "headers", None)
95+
96+
97+
def error_code(exc: ApiException) -> Optional[str]:
98+
"""The ``error.code`` of an API error response, or ``None``.
99+
100+
Prefers the deserialised ``exc.data`` (an ``ApiErrorResponse`` when the
101+
operation declares one for that status) and falls back to parsing the raw
102+
JSON body, so the check does not depend on which the generated client
103+
populated.
104+
"""
105+
data = getattr(exc, "data", None)
106+
detail = getattr(data, "error", None)
107+
code = getattr(detail, "code", None)
108+
if isinstance(code, str):
109+
return code
110+
body = getattr(exc, "body", None)
111+
if isinstance(body, bytes):
112+
try:
113+
body = body.decode("utf-8")
114+
except UnicodeDecodeError:
115+
return None
116+
if not isinstance(body, str) or not body:
117+
return None
118+
try:
119+
parsed = json.loads(body)
120+
except ValueError:
121+
return None
122+
if not isinstance(parsed, dict):
123+
return None
124+
detail = parsed.get("error")
125+
if not isinstance(detail, dict):
126+
return None
127+
code = detail.get("code")
128+
return code if isinstance(code, str) else None
129+
130+
131+
def is_resource_locked(exc: BaseException) -> bool:
132+
"""True for a 409 whose error code is ``RESOURCE_LOCKED``.
133+
134+
Matches on the code, never the message: ``CONFLICT`` and
135+
``IDEMPOTENCY_KEY_CONFLICT`` are also delivered as 409 and must not be
136+
retried.
137+
"""
138+
if not isinstance(exc, ApiException) or exc.status != _HTTP_CONFLICT:
139+
return False
140+
return error_code(exc) == RESOURCE_LOCKED_ERROR_CODE
141+
142+
143+
class _LoadRetryMixin:
144+
"""Shared retry loop for the ``/loads`` operations."""
145+
146+
load_retry: LoadRetryPolicy
147+
148+
def _backoff_delay(self, exc: ApiException, attempt: int) -> float:
149+
policy = self.load_retry
150+
retry_after = _parse_retry_after(getattr(exc, "headers", None))
151+
if retry_after is not None:
152+
# Honour the server's hint; jitter only ever adds on top, and the
153+
# per-delay cap does not apply (the deadline is the only bound).
154+
return retry_after + retry_after * policy.jitter * random.random()
155+
base = policy.base_backoff_s * (2 ** (attempt - 1))
156+
return min(base + base * policy.jitter * random.random(), policy.max_backoff_s)
157+
158+
def _call_with_lock_retry(
159+
self, what: str, fn: Callable[..., _T], *args: Any, **kwargs: Any
160+
) -> _T:
161+
policy = self.load_retry
162+
deadline = time.monotonic() + policy.deadline_s
163+
attempts = 0
164+
while True:
165+
attempts += 1
166+
try:
167+
return fn(*args, **kwargs)
168+
except ApiException as exc:
169+
if not is_resource_locked(exc):
170+
raise
171+
if attempts > policy.max_retries:
172+
raise TableLockedError(
173+
attempts=attempts,
174+
reason=(
175+
f"{what}: table still locked after {policy.max_retries} "
176+
"retries (HTTP 409 RESOURCE_LOCKED)"
177+
),
178+
last_exc=exc,
179+
) from exc
180+
delay = self._backoff_delay(exc, attempts)
181+
if time.monotonic() + delay > deadline:
182+
raise TableLockedError(
183+
attempts=attempts,
184+
reason=(
185+
f"{what}: table still locked after the "
186+
f"{policy.deadline_s}s retry budget (HTTP 409 RESOURCE_LOCKED)"
187+
),
188+
last_exc=exc,
189+
) from exc
190+
_log.info(
191+
"%s: another load holds the table (409 RESOURCE_LOCKED); "
192+
"retrying in %.1fs (attempt %d/%d)",
193+
what,
194+
delay,
195+
attempts,
196+
policy.max_retries,
197+
)
198+
time.sleep(delay)
199+
200+
201+
class DatabasesApi(_LoadRetryMixin, _GeneratedDatabasesApi):
202+
"""Drop-in replacement for :class:`hotdata.api.databases_api.DatabasesApi`
203+
whose :meth:`load_database_table` retries while another load holds the
204+
table (HTTP 409 ``RESOURCE_LOCKED``). All other methods work unchanged.
205+
206+
:param load_retry: Retry policy for locked tables; defaults to
207+
:class:`LoadRetryPolicy`.
208+
"""
209+
210+
def __init__(
211+
self, api_client: Any = None, *, load_retry: Optional[LoadRetryPolicy] = None
212+
) -> None:
213+
super().__init__(api_client)
214+
self.load_retry = load_retry or LoadRetryPolicy()
215+
216+
def load_database_table(self, *args: Any, **kwargs: Any) -> Any:
217+
"""Load a database table, retrying while another load holds it.
218+
219+
Same signature and result as the generated
220+
:meth:`~hotdata.api.databases_api.DatabasesApi.load_database_table`.
221+
A 409 with error code ``RESOURCE_LOCKED`` is retried per
222+
:attr:`load_retry` (honouring ``Retry-After``); any other error,
223+
including a 409 ``CONFLICT`` / ``IDEMPOTENCY_KEY_CONFLICT``, is raised
224+
unchanged.
225+
226+
:raises TableLockedError: the table stayed locked past the retry budget.
227+
"""
228+
return self._call_with_lock_retry(
229+
"load_database_table", super().load_database_table, *args, **kwargs
230+
)
231+
232+
233+
class ConnectionsApi(_LoadRetryMixin, _GeneratedConnectionsApi):
234+
"""Drop-in replacement for :class:`hotdata.api.connections_api.ConnectionsApi`
235+
whose :meth:`load_managed_table` retries while another load holds the
236+
table (HTTP 409 ``RESOURCE_LOCKED``). All other methods work unchanged.
237+
238+
:param load_retry: Retry policy for locked tables; defaults to
239+
:class:`LoadRetryPolicy`.
240+
"""
241+
242+
def __init__(
243+
self, api_client: Any = None, *, load_retry: Optional[LoadRetryPolicy] = None
244+
) -> None:
245+
super().__init__(api_client)
246+
self.load_retry = load_retry or LoadRetryPolicy()
247+
248+
def load_managed_table(self, *args: Any, **kwargs: Any) -> Any:
249+
"""Load a managed table, retrying while another load holds it.
250+
251+
Same signature and result as the generated
252+
:meth:`~hotdata.api.connections_api.ConnectionsApi.load_managed_table`.
253+
A 409 with error code ``RESOURCE_LOCKED`` is retried per
254+
:attr:`load_retry` (honouring ``Retry-After``); any other error,
255+
including a 409 ``CONFLICT`` / ``IDEMPOTENCY_KEY_CONFLICT``, is raised
256+
unchanged.
257+
258+
:raises TableLockedError: the table stayed locked past the retry budget.
259+
"""
260+
return self._call_with_lock_retry(
261+
"load_managed_table", super().load_managed_table, *args, **kwargs
262+
)
263+
264+
265+
__all__ = [
266+
"RESOURCE_LOCKED_ERROR_CODE",
267+
"ConnectionsApi",
268+
"DatabasesApi",
269+
"LoadRetryPolicy",
270+
"TableLockedError",
271+
"error_code",
272+
"is_resource_locked",
273+
]

0 commit comments

Comments
 (0)