Skip to content

Commit a350523

Browse files
authored
Merge pull request TangleML#19 from TangleML/piforge/failed-submit-recovery-backoff
fix(pipeline-run): back off failed-submit recovery lookups
2 parents 322f40e + 475975f commit a350523

2 files changed

Lines changed: 96 additions & 17 deletions

File tree

packages/tangle-cli/src/tangle_cli/pipeline_run_manager.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@
3737
_EXECUTION_STATE_TIMINGS_METADATA_KEY = "execution_state_timings"
3838
_EXECUTION_STATE_TIMING_MONOTONIC_METADATA_KEY = "_execution_state_timing_monotonic"
3939
_SUBMISSION_ID_ANNOTATION_KEY = "tangle-cli/submission-id"
40-
_SUBMIT_RECOVERY_LOOKUP_ATTEMPTS = 2
41-
_SUBMIT_RECOVERY_LOOKUP_DELAY_SECONDS = 0.1
40+
_SUBMIT_RECOVERY_BACKOFF_SECONDS = (0.5, 1.0, 2.0)
4241

4342

4443
class PipelineRunError(RuntimeError):
@@ -1557,11 +1556,19 @@ def _recover_submitted_run_after_submit_error(
15571556
) -> dict[str, Any] | None:
15581557
if not submission_id:
15591558
return None
1560-
for lookup_attempt in range(1, _SUBMIT_RECOVERY_LOOKUP_ATTEMPTS + 1):
1559+
total_lookup_attempts = len(_SUBMIT_RECOVERY_BACKOFF_SECONDS)
1560+
for lookup_attempt, delay_seconds in enumerate(_SUBMIT_RECOVERY_BACKOFF_SECONDS, start=1):
1561+
self.logger.info(
1562+
"Waiting "
1563+
f"{delay_seconds:g}s before checking whether failed submit already created a pipeline run "
1564+
f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}, "
1565+
f"lookup_attempt={lookup_attempt}/{total_lookup_attempts})"
1566+
)
1567+
time.sleep(delay_seconds)
15611568
self.logger.info(
15621569
"Checking whether failed submit already created a pipeline run "
15631570
f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}, "
1564-
f"lookup_attempt={lookup_attempt}/{_SUBMIT_RECOVERY_LOOKUP_ATTEMPTS})"
1571+
f"lookup_attempt={lookup_attempt}/{total_lookup_attempts})"
15651572
)
15661573
try:
15671574
matches = self._submitted_runs_for_submission_id(submission_id)
@@ -1598,8 +1605,6 @@ def _recover_submitted_run_after_submit_error(
15981605
f"{_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}: {', '.join(run_ids) or matches!r}. "
15991606
"Refusing to submit a duplicate."
16001607
)
1601-
if lookup_attempt < _SUBMIT_RECOVERY_LOOKUP_ATTEMPTS:
1602-
time.sleep(_SUBMIT_RECOVERY_LOOKUP_DELAY_SECONDS)
16031608
self.logger.warn(
16041609
"No existing pipeline run found after submit failure "
16051610
f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}); "

tests/test_pipeline_runs_cli.py

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,16 @@
1111
import yaml
1212

1313
from tangle_cli import cli, pipeline_run_manager, pipeline_runs_cli
14-
from tangle_cli.pipeline_runner import PipelineRunner, PipelineRunnerHooks
1514
from tangle_cli.pipeline_run_manager import (
15+
AmbiguousPipelineRunRecoveryError,
1616
PipelineRunContext,
17+
PipelineRunError,
1718
PipelineRunHooks,
1819
PipelineRunManager,
19-
PipelineRunError,
2020
PipelineWaitOutcome,
2121
PipelineWaitPoll,
2222
)
23+
from tangle_cli.pipeline_runner import PipelineRunner, PipelineRunnerHooks
2324

2425

2526
def run_app(app, args: list[str]) -> None:
@@ -199,7 +200,10 @@ def test_pipeline_runs_submit_dry_run_prints_sanitized_payload(monkeypatch, tmp_
199200
"arguments": {"config": {"_meta": {"mode": "keep"}}},
200201
"componentRef": {
201202
"name": "text-component",
202-
"text": "name: Text Component\n_source_dir: /tmp/private\nimplementation:\n container:\n image: busybox\n",
203+
"text": (
204+
"name: Text Component\n_source_dir: /tmp/private\nimplementation:\n"
205+
" container:\n image: busybox\n"
206+
),
203207
}
204208
}
205209
}
@@ -970,7 +974,9 @@ def fake_regenerate_yaml(self, **kwargs):
970974

971975
assert json.loads(capsys.readouterr().out)["id"] == "run-1"
972976
assert regenerated == [outside_python.resolve()]
973-
submitted_task = fake_client.created[0]["root_task"]["componentRef"]["spec"]["implementation"]["graph"]["tasks"]["generated"]
977+
submitted_task = fake_client.created[0]["root_task"]["componentRef"]["spec"]["implementation"]["graph"][
978+
"tasks"
979+
]["generated"]
974980
assert submitted_task["componentRef"]["name"] == "Submit Generated Component"
975981

976982

@@ -1670,9 +1676,9 @@ def after_poll(self, poll, context):
16701676
]
16711677

16721678

1673-
16741679
def test_pipeline_runs_submit_failure_recovery_adopts_existing_run(tmp_path: Path, monkeypatch) -> None:
1675-
monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda _seconds: None)
1680+
sleeps: list[float] = []
1681+
monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value))
16761682
pipeline_path = tmp_path / "pipeline.yaml"
16771683

16781684
class DynamicTimeHooks(PipelineRunnerHooks):
@@ -1727,18 +1733,82 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]:
17271733
assert hooks.prepare_run_arguments_calls == 1
17281734
assert hooks.submit_errors == []
17291735
assert len(client.created) == 1
1736+
assert sleeps == [0.5]
17301737
submission_id = client.created[0]["annotations"]["tangle-cli/submission-id"]
17311738
assert submission_id
17321739
assert len(client.list_calls) == 1
17331740
assert "tangle-cli/submission-id" in client.list_calls[0]["filter_query"]
17341741
assert submission_id in client.list_calls[0]["filter_query"]
17351742

17361743

1744+
def test_pipeline_runs_submit_failure_recovery_waits_for_delayed_registration(monkeypatch) -> None:
1745+
sleeps: list[float] = []
1746+
monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value))
1747+
1748+
class DelayedRecoveryClient(FakeClient):
1749+
def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]:
1750+
self.created.append(copy.deepcopy(body))
1751+
raise TimeoutError("submit timed out")
1752+
1753+
def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]:
1754+
self.list_calls.append(kwargs)
1755+
if len(self.list_calls) <= 2:
1756+
return {"pipeline_runs": [], "next_page_token": None}
1757+
return {
1758+
"pipeline_runs": [{"id": "run-created", "root_execution_id": "exec-created"}],
1759+
"next_page_token": None,
1760+
}
1761+
1762+
client = DelayedRecoveryClient()
1763+
manager = PipelineRunManager(client=client)
1764+
body = {"root_task": {"componentRef": {"spec": {"name": "delayed-recovery"}}}}
1765+
1766+
result = manager.run_prepared_body(body)
1767+
1768+
assert result["response"]["id"] == "run-created"
1769+
assert result["context"].metadata["recovered_after_submit_error"] is True
1770+
assert len(client.created) == 1
1771+
assert len(client.list_calls) == 3
1772+
assert sleeps == [0.5, 1.0, 2.0]
1773+
1774+
1775+
def test_pipeline_runs_submit_failure_recovery_refuses_ambiguous_matches(monkeypatch) -> None:
1776+
sleeps: list[float] = []
1777+
monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value))
1778+
1779+
class AmbiguousRecoveryClient(FakeClient):
1780+
def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]:
1781+
self.created.append(copy.deepcopy(body))
1782+
raise TimeoutError("submit timed out")
1783+
1784+
def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]:
1785+
self.list_calls.append(kwargs)
1786+
return {
1787+
"pipeline_runs": [
1788+
{"id": "run-created-1", "root_execution_id": "exec-created-1"},
1789+
{"id": "run-created-2", "root_execution_id": "exec-created-2"},
1790+
],
1791+
"next_page_token": None,
1792+
}
1793+
1794+
client = AmbiguousRecoveryClient()
1795+
manager = PipelineRunManager(client=client)
1796+
body = {"root_task": {"componentRef": {"spec": {"name": "ambiguous-recovery"}}}}
1797+
1798+
with pytest.raises(AmbiguousPipelineRunRecoveryError):
1799+
manager.run_prepared_body(body, max_attempts=2)
1800+
1801+
assert len(client.created) == 1
1802+
assert len(client.list_calls) == 1
1803+
assert sleeps == [0.5]
1804+
1805+
17371806
def test_pipeline_runs_submit_failure_reuses_frozen_body_when_recovery_finds_no_run(
17381807
tmp_path: Path,
17391808
monkeypatch,
17401809
) -> None:
1741-
monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda _seconds: None)
1810+
sleeps: list[float] = []
1811+
monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value))
17421812
pipeline_path = tmp_path / "pipeline.yaml"
17431813

17441814
class DynamicTimeHooks(PipelineRunnerHooks):
@@ -1799,12 +1869,14 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]:
17991869
assert client.created[0]["annotations"]["tangle-cli/submission-id"] == client.created[1]["annotations"][
18001870
"tangle-cli/submission-id"
18011871
]
1802-
assert len(client.list_calls) == 4
1803-
1872+
assert len(client.list_calls) == 2 * len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS)
1873+
expected_sleeps = list(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS)
1874+
assert sleeps == expected_sleeps + expected_sleeps
18041875

18051876

18061877
def test_pipeline_runs_recovered_retry_runs_after_retry_submit_hook(tmp_path: Path, monkeypatch) -> None:
1807-
monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda _seconds: None)
1878+
sleeps: list[float] = []
1879+
monkeypatch.setattr("tangle_cli.pipeline_run_manager.time.sleep", lambda value: sleeps.append(value))
18081880
pipeline_path = tmp_path / "pipeline.yaml"
18091881
events: list[tuple[str, int, str | None]] = []
18101882

@@ -1842,7 +1914,7 @@ def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]:
18421914

18431915
def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]:
18441916
self.list_calls.append(kwargs)
1845-
if len(self.list_calls) <= 2:
1917+
if len(self.list_calls) <= len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS):
18461918
return {"pipeline_runs": [], "next_page_token": None}
18471919
return {
18481920
"pipeline_runs": [
@@ -1862,6 +1934,8 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]:
18621934
assert result["context"].metadata["recovered_after_submit_error"] is True
18631935
assert hooks.prepare_run_arguments_calls == 1
18641936
assert len(client.created) == 1
1937+
assert len(client.list_calls) == len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS) + 1
1938+
assert sleeps == [0.5, 1.0, 2.0, 0.5]
18651939
assert events == [("before_retry", 2, None), ("after_retry_submit", 2, "run-created")]
18661940

18671941

0 commit comments

Comments
 (0)