Skip to content

Commit e3672cd

Browse files
Merge pull request #44 from hotdata-dev/fix/append-at-most-once
fix: run append loads at-most-once to prevent retry duplication
2 parents 099ce38 + 427c921 commit e3672cd

4 files changed

Lines changed: 74 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- `load_managed_table(..., mode=...)` selects the load mode (`replace` (default), `append`, `delete`, `update`, `upsert`) instead of always replacing the table. `replace`/`append` apply the upload directly; `delete`/`update`/`upsert` match rows by the table's declared key. Backward compatible — omitting `mode` still replaces.
1313
- `create_managed_database(..., keys={table: [cols]})` and `add_managed_table(..., key=[cols])` declare a table's row-identity key, enabling the key-based load modes on it. Requires a `hotdata` client whose managed-table decl models carry `key` (see the dependency floor bump); tables declared without a key stay `replace`/`append`-only.
1414

15+
### Fixed
16+
17+
- `load_managed_table(..., mode="append")` is no longer retried on transient errors. Every other mode is idempotent, but retrying an `append` whose commit succeeded before the response was received would duplicate the uploaded rows; `append` now runs at most once. `mode` is also now typed as a literal of the accepted values.
18+
1519
## [0.6.3] - 2026-07-08
1620

1721
### Added

‎hotdata_framework/client.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import time
55
from collections.abc import Iterator
66
from dataclasses import asdict, dataclass
7-
from typing import Any
7+
from typing import Any, Literal
88

99
from hotdata import ApiClient, Configuration
1010
from hotdata.api.connections_api import ConnectionsApi
@@ -46,6 +46,10 @@
4646
from hotdata_framework.http import default_http_retries
4747
from hotdata_framework.result import QueryResult
4848

49+
# Load modes the managed-table endpoint accepts: replace overwrites, append adds
50+
# rows, delete/update/upsert match by the table's declared key.
51+
ManagedLoadMode = Literal["replace", "append", "delete", "update", "upsert"]
52+
4953
_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
5054
_RESULT_FAILURE = frozenset({"failed", "cancelled"})
5155

@@ -321,7 +325,7 @@ def load_managed_table(
321325
schema: str = DEFAULT_SCHEMA,
322326
upload_id: str | None = None,
323327
file: str | None = None,
324-
mode: str = "replace",
328+
mode: ManagedLoadMode = "replace",
325329
) -> LoadManagedTableResult:
326330
if (upload_id is None) == (file is None):
327331
raise ValueError("Exactly one of upload_id or file is required")

‎hotdata_framework/managed_client.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from hotdata.models.query_response import QueryResponse
2222

2323
from hotdata_framework.client import HotdataClient as RuntimeClient
24+
from hotdata_framework.client import ManagedLoadMode
2425
from hotdata_framework.databases import LoadManagedTableResult, ManagedDatabase
2526
from hotdata_framework.errors import (
2627
HotdataTransientError,
@@ -203,25 +204,30 @@ def load_managed_table(
203204
*,
204205
schema: str,
205206
upload_id: str,
206-
mode: str = "replace",
207+
mode: ManagedLoadMode = "replace",
207208
) -> LoadManagedTableResult:
209+
# append is the only non-idempotent mode: if the server commits the load
210+
# but the response is lost, a retry re-appends the same rows. Run it
211+
# at-most-once; every other mode is safe to retry.
208212
return self._request_with_retry(
209213
lambda: self._runtime.load_managed_table(
210214
database,
211215
table,
212216
schema=schema,
213217
upload_id=upload_id,
214218
mode=mode,
215-
)
219+
),
220+
retryable=(mode != "append"),
216221
)
217222

218-
def _request_with_retry(self, operation: Callable[[], T]) -> T:
219-
for attempt in range(1, self._max_retries + 1):
223+
def _request_with_retry(self, operation: Callable[[], T], *, retryable: bool = True) -> T:
224+
max_attempts = self._max_retries if retryable else 1
225+
for attempt in range(1, max_attempts + 1):
220226
try:
221227
return operation()
222228
except Exception as error:
223229
mapped_error = classify_sdk_error(error.__cause__ or error)
224-
if isinstance(mapped_error, HotdataTransientError) and attempt < self._max_retries:
230+
if isinstance(mapped_error, HotdataTransientError) and attempt < max_attempts:
225231
backoff = min(self._retry_backoff_seconds * attempt, self._MAX_BACKOFF_SECONDS)
226232
time.sleep(backoff)
227233
continue

‎tests/test_managed_client.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,56 @@ def get_result_arrow(self, result_id: str, *, x_database_id: str) -> pa.Table:
156156
assert table is not None
157157
assert result_scopes == ["db1"]
158158
assert arrow_scopes == ["db1"]
159+
160+
161+
def _load_recording_runtime(calls: list[str]) -> SimpleNamespace:
162+
"""A runtime whose ``load_managed_table`` records each mode and always fails
163+
with a transient error, so retry behaviour is observable via ``calls``."""
164+
165+
def load_managed_table(
166+
database: str, table: str, *, schema: str, upload_id: str, mode: str
167+
) -> SimpleNamespace:
168+
calls.append(mode)
169+
raise TimeoutError("commit succeeded but response was lost")
170+
171+
runtime = _fake_runtime()
172+
runtime.load_managed_table = load_managed_table
173+
return runtime
174+
175+
176+
def _managed_client(max_retries: int) -> Any:
177+
return mc.ManagedDatabaseClient(
178+
api_key="k",
179+
workspace_id="w",
180+
api_base_url="https://example.test",
181+
max_retries=max_retries,
182+
retry_backoff_seconds=0.0,
183+
)
184+
185+
186+
def test_append_load_runs_at_most_once(monkeypatch: pytest.MonkeyPatch) -> None:
187+
"""``append`` is not idempotent: retrying after a commit whose response was
188+
lost would duplicate rows. A transient failure must surface immediately
189+
without re-appending, even with retries budgeted."""
190+
monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
191+
calls: list[str] = []
192+
client = _managed_client(max_retries=8)
193+
client._runtime = _load_recording_runtime(calls)
194+
195+
with pytest.raises(mc.HotdataTransientError):
196+
client.load_managed_table("db", "orders", schema="public", upload_id="u1", mode="append")
197+
198+
assert calls == ["append"] # tried once, never retried
199+
200+
201+
def test_idempotent_load_retries_on_transient(monkeypatch: pytest.MonkeyPatch) -> None:
202+
"""Idempotent modes still exhaust the retry budget on transient errors."""
203+
monkeypatch.setattr(mc.time, "sleep", lambda _seconds: None)
204+
calls: list[str] = []
205+
client = _managed_client(max_retries=3)
206+
client._runtime = _load_recording_runtime(calls)
207+
208+
with pytest.raises(mc.HotdataTransientError):
209+
client.load_managed_table("db", "orders", schema="public", upload_id="u1", mode="replace")
210+
211+
assert calls == ["replace", "replace", "replace"] # retried up to max_retries

0 commit comments

Comments
 (0)