Skip to content

Commit f3dca49

Browse files
committed
fix(load): measure poll-error tolerance in time, and name the job when giving up
Two review points, both on paths where the caller is left not knowing whether the load landed. TOLERANCE WAS A COUNT, WHICH MEANS NOTHING ON ITS OWN. Five consecutive failures at the load's 2s interval is about eight seconds, and the thing being survived is a gateway blip: a rolling restart or a load balancer reconverging serves 502s for longer than that. Past it the poll aborted, the caller's retry re-submitted the load, and the first job was still holding the table -- the door this tolerance exists to close, just narrower than before. A count is also the wrong unit, because what it means in wall-clock depends on whichever `interval_s` the caller passed, and callers differ. Now a time bound: give up after 120s of CONTINUOUS failure, reset on any success, still bounded by the poll's own deadline. Failed checks also back off to 15s rather than retrying every 2s -- whatever is serving 502s does not need the extra traffic. THE GIVE-UP PATHS DROPPED THE JOB ID. `api_error_message(e)` is the gateway's message ("502: Bad Gateway"), and the failed-job raise preferred the server's message, which need not mention the id. Those are exactly the two paths where the answer is unknown and the id is the only handle for asking. Both name the job now, matching what `_poll_job`'s own TimeoutError already did. The test for the tolerance drives a FAKE CLOCK advanced by the sleeps. With `sleep` patched to a no-op and a real clock, it waited out the whole grace period in wall time -- 120 seconds of a spinning loop to assert one message, and it slowed the suite from 0.5s to 121s before I noticed. A test measuring a time bound has to own the clock. 152 passed in 0.53s. `ruff check` clean on the touched files.
1 parent 127aae1 commit f3dca49

2 files changed

Lines changed: 121 additions & 19 deletions

File tree

hotdata_framework/client.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,23 @@
9595
# first place. Bounded rather than unbounded so a wedged job still surfaces.
9696
_LOAD_JOB_TIMEOUT_S = 3600.0
9797

98-
# Consecutive failed status checks before a poll gives up.
99-
_JOB_POLL_MAX_CONSECUTIVE_ERRORS = 5
98+
# How long a poll tolerates CONTINUOUS status-check failure before giving up.
99+
#
100+
# Time, not a count: a count means whatever the caller's `interval_s` makes it,
101+
# and callers differ -- five checks is eight seconds at the load interval and
102+
# something else at the index one. The thing being survived is a gateway blip,
103+
# and a rolling restart or a load balancer reconverging routinely serves 502s for
104+
# longer than a few seconds. Too short and the poll aborts, the caller's retry
105+
# re-submits the load, and the original job is still holding the table -- the
106+
# door this tolerance exists to close.
107+
#
108+
# Still bounded by the poll's own deadline, so this only decides how a stretch of
109+
# failures ends, never how long the wait can be.
110+
_JOB_POLL_ERROR_GRACE_S = 120.0
111+
112+
# Failed checks back off rather than hammering at `interval_s`: whatever is
113+
# serving 502s does not need the extra traffic.
114+
_JOB_POLL_ERROR_MAX_BACKOFF_S = 15.0
100115

101116

102117
@dataclass(frozen=True)
@@ -921,11 +936,14 @@ def _poll_job(
921936
jobs = self._jobs_api()
922937
deadline = time.monotonic() + timeout_s
923938
last: JobStatusResponse | None = None
924-
consecutive_errors = 0
939+
# When the current run of failures began, or None while checks succeed.
940+
failing_since: float | None = None
941+
error_backoff = interval_s
925942
while time.monotonic() < deadline:
926943
try:
927944
last = jobs.get_job(job_id)
928-
consecutive_errors = 0
945+
failing_since = None
946+
error_backoff = interval_s
929947
except ApiException as e:
930948
# A failed STATUS CHECK is not a failed job. Aborting here throws
931949
# away work that is still running, and the caller's retry then
@@ -937,10 +955,20 @@ def _poll_job(
937955
# CONSECUTIVE failures are the signal: an isolated 502 is noise, a
938956
# run of them means the API is gone and there is nothing to wait
939957
# for. The poll's own deadline bounds the total wait regardless.
940-
consecutive_errors += 1
941-
if consecutive_errors >= _JOB_POLL_MAX_CONSECUTIVE_ERRORS:
942-
raise RuntimeError(api_error_message(e)) from e
943-
time.sleep(interval_s)
958+
now = time.monotonic()
959+
if failing_since is None:
960+
failing_since = now
961+
elif now - failing_since >= _JOB_POLL_ERROR_GRACE_S:
962+
# Name the job. This is one of the two paths where the caller
963+
# cannot tell whether the load landed, so the id is the only
964+
# thing that makes the question answerable -- and it is exactly
965+
# what a message like "502: Bad Gateway" leaves out.
966+
raise RuntimeError(
967+
f"Job {job_id} status checks failed for "
968+
f"{_JOB_POLL_ERROR_GRACE_S:.0f}s: {api_error_message(e)}"
969+
) from e
970+
time.sleep(error_backoff)
971+
error_backoff = min(error_backoff * 2, _JOB_POLL_ERROR_MAX_BACKOFF_S)
944972
continue
945973
if last.status in _JOB_TERMINAL:
946974
return last
@@ -968,9 +996,11 @@ def _load_response_from_job(self, job_id: str) -> LoadManagedTableResponse:
968996
final = self._poll_job(job_id, timeout_s=_LOAD_JOB_TIMEOUT_S)
969997
status = enum_value(final.status)
970998
if status != "succeeded":
971-
raise RuntimeError(
972-
final.error_message or f"load job {job_id} finished {status}"
973-
)
999+
# The id goes in whether or not the server's message mentions it: the
1000+
# caller is being told the load did not succeed, and "which load" is
1001+
# the next thing it needs.
1002+
detail = final.error_message or f"finished {status}"
1003+
raise RuntimeError(f"load job {job_id} {status}: {detail}")
9741004
# `result` is a oneOf wrapper today; tolerate the model arriving directly,
9751005
# the same way the index path does rather than disagreeing with it.
9761006
payload = getattr(final.result, "actual_instance", final.result)

tests/test_client.py

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,7 @@ def test_a_load_job_gets_its_own_budget_not_the_query_one():
640640
the first place; a load is the one operation whose duration scales with the
641641
data."""
642642
from hotdata.models.submit_job_response import SubmitJobResponse
643+
643644
from hotdata_framework.client import _LOAD_JOB_TIMEOUT_S
644645

645646
assert _LOAD_JOB_TIMEOUT_S > 300.0
@@ -744,28 +745,99 @@ def get_job(self, job_id):
744745
assert calls["n"] == 3
745746

746747

747-
def test_a_run_of_failed_status_checks_still_gives_up():
748-
"""Tolerance is for blips, not for an API that has gone away."""
748+
def test_a_sustained_run_of_failed_status_checks_gives_up_naming_the_job():
749+
"""Tolerance is for blips, not for an API that has gone away -- and the message
750+
has to name the job, because this is one of the two paths where the caller
751+
cannot tell whether the load landed. `502: Bad Gateway` alone does not."""
749752
from hotdata.exceptions import ApiException
750-
from hotdata_framework.client import _JOB_POLL_MAX_CONSECUTIVE_ERRORS
753+
754+
from hotdata_framework.client import _JOB_POLL_ERROR_GRACE_S
751755

752756
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
753757

754758
class _Dead:
755759
def get_job(self, job_id):
756760
raise ApiException(status=502, reason="Bad Gateway")
757761

762+
# A FAKE CLOCK, advanced by the sleeps. The tolerance is measured in time, so
763+
# a no-op `sleep` with a real clock would make this test wait out the whole
764+
# grace period in wall time -- 120 seconds of a spinning loop to assert one
765+
# message.
766+
clock = {"t": 0.0}
758767
with (
759768
patch.object(client, "_jobs_api", return_value=_Dead()),
760-
patch("time.sleep", lambda *_: None),
769+
patch("time.monotonic", lambda: clock["t"]),
770+
patch("time.sleep", lambda s: clock.__setitem__("t", clock["t"] + s)),
761771
):
762772
try:
763-
client._poll_job("jobs_1", timeout_s=600.0, interval_s=0.01)
764-
except RuntimeError:
765-
pass
773+
client._poll_job("jobs_1", timeout_s=6000.0, interval_s=1.0)
774+
except RuntimeError as e:
775+
assert "jobs_1" in str(e), f"gave up without naming the job: {e}"
766776
else:
767777
raise AssertionError("polled forever against a dead API")
768-
assert _JOB_POLL_MAX_CONSECUTIVE_ERRORS >= 2
778+
assert clock["t"] >= _JOB_POLL_ERROR_GRACE_S, "gave up before the grace elapsed"
779+
assert clock["t"] < 6000.0, "ran to the poll deadline instead of the grace"
780+
781+
782+
def test_the_error_tolerance_is_measured_in_time_not_checks():
783+
"""A count means whatever the caller's `interval_s` makes it, and callers
784+
differ. The thing being survived is a gateway blip, which lasts seconds to
785+
minutes regardless of how often we happen to ask."""
786+
from hotdata_framework.client import _JOB_POLL_ERROR_GRACE_S
787+
788+
# long enough to outlast a rolling restart / LB reconverge, not seconds
789+
assert _JOB_POLL_ERROR_GRACE_S >= 60.0
790+
791+
792+
def test_failed_status_checks_back_off_instead_of_hammering():
793+
"""Whatever is serving 502s does not need the extra traffic."""
794+
from hotdata.exceptions import ApiException
795+
796+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
797+
sleeps: list[float] = []
798+
calls = {"n": 0}
799+
final = SimpleNamespace(status="succeeded", error_message=None,
800+
result=SimpleNamespace(actual_instance=_load_response()))
801+
802+
class _Flaky:
803+
def get_job(self, job_id):
804+
calls["n"] += 1
805+
if calls["n"] < 5:
806+
raise ApiException(status=502, reason="Bad Gateway")
807+
return final
808+
809+
with (
810+
patch.object(client, "_jobs_api", return_value=_Flaky()),
811+
patch("time.sleep", lambda s: sleeps.append(s)),
812+
):
813+
client._poll_job("jobs_1", timeout_s=600.0, interval_s=1.0)
814+
815+
failed_waits = sleeps[:4]
816+
assert failed_waits == sorted(failed_waits), f"did not back off: {failed_waits}"
817+
assert failed_waits[-1] > failed_waits[0]
818+
819+
820+
def test_a_failed_load_job_names_the_job_alongside_the_server_message():
821+
from hotdata.models.submit_job_response import SubmitJobResponse
822+
823+
client = HotdataClient("k", "ws", host="https://api.hotdata.dev")
824+
db = ManagedDatabase(id="db_1", description="mydb", default_connection_id="conn_1")
825+
connections = _FakeConnectionsApi(responses=[
826+
SubmitJobResponse(id="jobs_42", status="running", status_url="/v1/jobs/jobs_42"),
827+
])
828+
final = SimpleNamespace(status="failed", error_message="disk full", result=None)
829+
830+
with (
831+
patch.object(client, "_databases_api", return_value=_ForbiddenDatabasesApi()),
832+
patch.object(client, "connections", return_value=connections),
833+
patch.object(client, "_poll_job", return_value=final),
834+
):
835+
try:
836+
client.load_managed_table(db, "orders", schema="public", upload_id="up_1")
837+
except RuntimeError as e:
838+
assert "disk full" in str(e) and "jobs_42" in str(e), e
839+
else:
840+
raise AssertionError("a failed load job did not raise")
769841

770842

771843
def test_a_deferred_load_returns_the_job_id_to_the_caller():

0 commit comments

Comments
 (0)