Skip to content

Commit e5cf7d6

Browse files
committed
feat(pipeline-run): configure submit recovery attempts
Assisted-By: devx/12bfa114-4ee7-4929-a37c-98018f6b0f41
1 parent a350523 commit e5cf7d6

8 files changed

Lines changed: 114 additions & 20 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@
1414
try:
1515
__version__ = metadata_version("tangle-cli")
1616
except PackageNotFoundError:
17-
__version__ = "0.1.0"
17+
__version__ = "0.1.1"
1818

1919
__all__ = ["TangleDynamicDiscoveryClient", "__version__"]

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
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_BACKOFF_SECONDS = (0.5, 1.0, 2.0)
40+
_SUBMIT_RECOVERY_BACKOFF_SECONDS = (0.5, 1.0, 2.0, 4.0, 8.0, 16.0)
41+
_DEFAULT_SUBMIT_RECOVERY_ATTEMPTS = 2
4142

4243

4344
class PipelineRunError(RuntimeError):
@@ -1528,6 +1529,11 @@ def _submission_id_from_body(body: Mapping[str, Any]) -> str | None:
15281529
submission_id = annotations.get(_SUBMISSION_ID_ANNOTATION_KEY)
15291530
return str(submission_id) if submission_id else None
15301531

1532+
@staticmethod
1533+
def _submit_recovery_backoff_seconds(submit_recovery_attempts: int) -> tuple[float, ...]:
1534+
attempt_count = max(0, min(int(submit_recovery_attempts), len(_SUBMIT_RECOVERY_BACKOFF_SECONDS)))
1535+
return _SUBMIT_RECOVERY_BACKOFF_SECONDS[:attempt_count]
1536+
15311537
def _submitted_runs_for_submission_id(self, submission_id: str) -> list[dict[str, Any]]:
15321538
query = {
15331539
"and": [
@@ -1553,11 +1559,21 @@ def _recover_submitted_run_after_submit_error(
15531559
self,
15541560
*,
15551561
submission_id: str | None,
1562+
submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
15561563
) -> dict[str, Any] | None:
15571564
if not submission_id:
15581565
return None
1559-
total_lookup_attempts = len(_SUBMIT_RECOVERY_BACKOFF_SECONDS)
1560-
for lookup_attempt, delay_seconds in enumerate(_SUBMIT_RECOVERY_BACKOFF_SECONDS, start=1):
1566+
backoff_seconds = self._submit_recovery_backoff_seconds(submit_recovery_attempts)
1567+
total_lookup_attempts = len(backoff_seconds)
1568+
if total_lookup_attempts == 0:
1569+
self.logger.warn(
1570+
"Submit recovery lookup disabled "
1571+
f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}, "
1572+
f"submit_recovery_attempts={submit_recovery_attempts}); "
1573+
"resubmitting the same frozen body with preserved inputs."
1574+
)
1575+
return None
1576+
for lookup_attempt, delay_seconds in enumerate(backoff_seconds, start=1):
15611577
self.logger.info(
15621578
"Waiting "
15631579
f"{delay_seconds:g}s before checking whether failed submit already created a pipeline run "
@@ -1652,6 +1668,7 @@ def _run_body_factory(
16521668
timeout_clock: str = "monotonic",
16531669
exit_on_first_failure: bool = False,
16541670
metadata: dict[str, Any] | None = None,
1671+
submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
16551672
metadata_factory: Callable[
16561673
[int, PipelineRunContext | None, Exception | None], dict[str, Any]
16571674
] | None = None,
@@ -1728,6 +1745,7 @@ def _run_body_factory(
17281745
if reused_after_submit_failure:
17291746
recovered_response = self._recover_submitted_run_after_submit_error(
17301747
submission_id=self._submission_id_from_body(body),
1748+
submit_recovery_attempts=submit_recovery_attempts,
17311749
)
17321750
if recovered_response is not None:
17331751
response = self._adopt_submitted_run(
@@ -1759,6 +1777,7 @@ def _run_body_factory(
17591777
)
17601778
recovered_response = self._recover_submitted_run_after_submit_error(
17611779
submission_id=submission_id_for_recovery,
1780+
submit_recovery_attempts=submit_recovery_attempts,
17621781
)
17631782
if recovered_response is None:
17641783
self.hooks.on_submit_error(submit_exc, context=context)
@@ -1839,6 +1858,7 @@ def run_prepared_body(
18391858
timeout_clock: str = "monotonic",
18401859
exit_on_first_failure: bool = False,
18411860
metadata: dict[str, Any] | None = None,
1861+
submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
18421862
) -> dict[str, Any]:
18431863
"""Submit/wait/retry an already prepared submit body.
18441864
@@ -1867,6 +1887,7 @@ def body_factory(
18671887
timeout_clock=timeout_clock,
18681888
exit_on_first_failure=exit_on_first_failure,
18691889
metadata=metadata,
1890+
submit_recovery_attempts=submit_recovery_attempts,
18701891
)
18711892

18721893
def run_pipeline_spec(
@@ -1887,6 +1908,7 @@ def run_pipeline_spec(
18871908
timeout_clock: str = "monotonic",
18881909
exit_on_first_failure: bool = False,
18891910
metadata: dict[str, Any] | None = None,
1911+
submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
18901912
) -> dict[str, Any]:
18911913
"""Submit/wait/retry an already hydrated/validated in-memory spec."""
18921914

@@ -1916,6 +1938,7 @@ def body_factory(
19161938
timeout_clock=timeout_clock,
19171939
exit_on_first_failure=exit_on_first_failure,
19181940
metadata=metadata,
1941+
submit_recovery_attempts=submit_recovery_attempts,
19191942
)
19201943

19211944
def run_pipeline(
@@ -1936,6 +1959,7 @@ def run_pipeline(
19361959
timeout_clock: str = "monotonic",
19371960
exit_on_first_failure: bool = False,
19381961
metadata: dict[str, Any] | None = None,
1962+
submit_recovery_attempts: int = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
19391963
) -> dict[str, Any]:
19401964
"""Submit (and optionally wait for) a pipeline with lifecycle hooks.
19411965
@@ -1970,6 +1994,7 @@ def body_factory(
19701994
timeout_clock=timeout_clock,
19711995
exit_on_first_failure=exit_on_first_failure,
19721996
metadata=metadata,
1997+
submit_recovery_attempts=submit_recovery_attempts,
19731998
)
19741999

19752000

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@ def run_pipeline(
507507
open_browser: bool = False,
508508
include_next_steps: bool = False,
509509
metadata: dict[str, Any] | None = None,
510+
submit_recovery_attempts: int = 2,
510511
) -> dict[str, Any]:
511512
"""Run a pipeline path through generic preparation + lifecycle hooks.
512513
@@ -603,6 +604,7 @@ def metadata_factory(
603604
timeout_clock=timeout_clock,
604605
exit_on_first_failure=exit_on_first_failure,
605606
metadata_factory=metadata_factory,
607+
submit_recovery_attempts=submit_recovery_attempts,
606608
)
607609
context = result.get("context")
608610
attempt = context.attempt if isinstance(context, PipelineRunContext) else max(preparations)

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from .logger import Logger, logger_for_log_type
2929
from .pipeline_run_annotations import AnnotationManager
3030
from .pipeline_run_manager import (
31+
_DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
3132
PipelineRunError,
3233
PipelineRunHooks,
3334
PipelineRunManager,
@@ -166,6 +167,15 @@ def pipeline_runs_submit(
166167
auth_header: AuthHeaderOption = None,
167168
header: HeaderOption = None,
168169
config: ConfigOption = None,
170+
submit_recovery_attempts: Annotated[
171+
int,
172+
Parameter(
173+
help=(
174+
"Number of post-failed-submit recovery lookups before resubmitting; "
175+
"higher values wait longer for delayed run registration."
176+
)
177+
),
178+
] = _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS,
169179
log_type: LogTypeOption = "console",
170180
) -> None:
171181
"""Hydrate and submit a local pipeline YAML file as a run."""
@@ -181,6 +191,7 @@ def pipeline_runs_submit(
181191
"run_as": (run_as, None),
182192
"trusted_source": (trusted_source, None),
183193
"trusted_hydration_cli": ("trusted_hydration_cli", trusted_hydration, None, False),
194+
"submit_recovery_attempts": (submit_recovery_attempts, _DEFAULT_SUBMIT_RECOVERY_ATTEMPTS),
184195
"log_type": (log_type, "console"),
185196
**api_arg_specs(base_url=base_url, token=token, auth_header=auth_header, header=header),
186197
}
@@ -194,7 +205,12 @@ def action(manager: PipelineRunManager, args: ArgsContainer) -> dict[str, Any]:
194205
}
195206
if args.dry_run:
196207
return manager.build_submit_body(args.pipeline_path, **kwargs)
197-
return manager.submit_pipeline(args.pipeline_path, **kwargs)
208+
result = manager.run_pipeline(
209+
args.pipeline_path,
210+
**kwargs,
211+
submit_recovery_attempts=args.submit_recovery_attempts,
212+
)
213+
return result["response"]
198214

199215
_run_manager_action(config, base_url, specs, action)
200216

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "tangle-cli"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
description = "CLI for Tangle, the open-source ML pipeline orchestration platform"
55
readme = "README.md"
66
authors = [

tests/test_packaging.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
from tangle_cli.openapi import codegen
1616

17-
1817
_REPO_ROOT = Path(__file__).resolve().parents[1]
1918

2019

@@ -179,7 +178,7 @@ def test_tangle_cli_wheel_supports_expert_no_deps_import_path_without_tangle_api
179178
requires_dist = [line for line in metadata.splitlines() if line.startswith("Requires-Dist: ")]
180179
assert not any(name.startswith("tangle_api/") for name in names)
181180
assert "tangle_cli/openapi/openapi.json" not in names
182-
assert "Version: 0.1.0" in metadata
181+
assert "Version: 0.1.1" in metadata
183182
assert "Requires-Dist: tangle-api==0.1.0" in requires_dist
184183
assert not any("extra == 'native'" in line for line in requires_dist)
185184
assert "Provides-Extra: native" in metadata

tests/test_pipeline_runs_cli.py

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ def test_pipeline_runs_help_exposes_run_commands_not_local_pipeline_commands(cap
135135
assert "diagram" not in output
136136

137137
run_app(app, ["sdk", "pipeline-runs", "submit", "--help"])
138-
assert "--log-type" in capsys.readouterr().out
138+
submit_help = capsys.readouterr().out
139+
assert "--log-type" in submit_help
140+
assert "--submit-recovery-attempts" in submit_help
139141

140142

141143
def test_pipeline_runs_submit_builds_create_payload(monkeypatch, tmp_path: Path, capsys):
@@ -161,12 +163,62 @@ def test_pipeline_runs_submit_builds_create_payload(monkeypatch, tmp_path: Path,
161163

162164
result = json.loads(capsys.readouterr().out)
163165
assert result == {"id": "run-1", "root_execution_id": "exec-1"}
164-
assert fake_client.created[0]["annotations"] == {"team": "oss"}
166+
assert fake_client.created[0]["annotations"]["team"] == "oss"
167+
assert fake_client.created[0]["annotations"]["tangle-cli/submission-id"]
165168
root_task = fake_client.created[0]["root_task"]
166169
assert root_task["componentRef"]["spec"]["name"] == "Demo Pipeline"
167170
assert root_task["arguments"] == {"query": "default", "required": "value"}
168171

169172

173+
def test_pipeline_runs_submit_uses_default_submit_recovery_attempts(monkeypatch, tmp_path: Path, capsys):
174+
pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml")
175+
captured: dict[str, Any] = {}
176+
177+
def fake_run_pipeline(self, pipeline_path, **kwargs):
178+
del self, pipeline_path
179+
captured.update(kwargs)
180+
return {"response": {"id": "run-1"}}
181+
182+
monkeypatch.setattr(PipelineRunManager, "run_pipeline", fake_run_pipeline)
183+
monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: FakeClient())
184+
app = cli.build_app()
185+
186+
run_app(app, ["sdk", "pipeline-runs", "submit", str(pipeline_path), "--no-hydrate"])
187+
188+
assert json.loads(capsys.readouterr().out) == {"id": "run-1"}
189+
assert captured["submit_recovery_attempts"] == pipeline_run_manager._DEFAULT_SUBMIT_RECOVERY_ATTEMPTS
190+
191+
192+
def test_pipeline_runs_submit_accepts_submit_recovery_attempts(monkeypatch, tmp_path: Path, capsys):
193+
pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml")
194+
captured: dict[str, Any] = {}
195+
196+
def fake_run_pipeline(self, pipeline_path, **kwargs):
197+
del self, pipeline_path
198+
captured.update(kwargs)
199+
return {"response": {"id": "run-1"}}
200+
201+
monkeypatch.setattr(PipelineRunManager, "run_pipeline", fake_run_pipeline)
202+
monkeypatch.setattr(pipeline_runs_cli, "LazyTangleApiClient", lambda **kwargs: FakeClient())
203+
app = cli.build_app()
204+
205+
run_app(
206+
app,
207+
[
208+
"sdk",
209+
"pipeline-runs",
210+
"submit",
211+
str(pipeline_path),
212+
"--no-hydrate",
213+
"--submit-recovery-attempts",
214+
"6",
215+
],
216+
)
217+
218+
assert json.loads(capsys.readouterr().out) == {"id": "run-1"}
219+
assert captured["submit_recovery_attempts"] == 6
220+
221+
170222
def test_pipeline_runs_submit_accepts_export_config_args_and_hydrate(monkeypatch, tmp_path: Path):
171223
pipeline_path = _write_pipeline(tmp_path / "pipeline.yaml")
172224
config = tmp_path / "pipeline.config.yaml"
@@ -1752,7 +1804,7 @@ def pipeline_runs_create(self, body: Any = None) -> dict[str, Any]:
17521804

17531805
def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]:
17541806
self.list_calls.append(kwargs)
1755-
if len(self.list_calls) <= 2:
1807+
if len(self.list_calls) < len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS):
17561808
return {"pipeline_runs": [], "next_page_token": None}
17571809
return {
17581810
"pipeline_runs": [{"id": "run-created", "root_execution_id": "exec-created"}],
@@ -1763,13 +1815,13 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]:
17631815
manager = PipelineRunManager(client=client)
17641816
body = {"root_task": {"componentRef": {"spec": {"name": "delayed-recovery"}}}}
17651817

1766-
result = manager.run_prepared_body(body)
1818+
result = manager.run_prepared_body(body, submit_recovery_attempts=6)
17671819

17681820
assert result["response"]["id"] == "run-created"
17691821
assert result["context"].metadata["recovered_after_submit_error"] is True
17701822
assert len(client.created) == 1
1771-
assert len(client.list_calls) == 3
1772-
assert sleeps == [0.5, 1.0, 2.0]
1823+
assert len(client.list_calls) == len(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS)
1824+
assert sleeps == list(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS)
17731825

17741826

17751827
def test_pipeline_runs_submit_failure_recovery_refuses_ambiguous_matches(monkeypatch) -> None:
@@ -1869,8 +1921,8 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]:
18691921
assert client.created[0]["annotations"]["tangle-cli/submission-id"] == client.created[1]["annotations"][
18701922
"tangle-cli/submission-id"
18711923
]
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)
1924+
expected_sleeps = list(pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS[:2])
1925+
assert len(client.list_calls) == 2 * len(expected_sleeps)
18741926
assert sleeps == expected_sleeps + expected_sleeps
18751927

18761928

@@ -1927,15 +1979,15 @@ def pipeline_runs_list(self, **kwargs: Any) -> dict[str, Any]:
19271979
client = RecoverOnRetryClient()
19281980
manager = PipelineRunner(client=client, hooks=hooks)
19291981

1930-
result = manager.run_pipeline(pipeline_path, hydrate=False, max_attempts=2)
1982+
result = manager.run_pipeline(pipeline_path, hydrate=False, max_attempts=2, submit_recovery_attempts=6)
19311983

19321984
assert result["response"]["id"] == "run-created"
19331985
assert result["context"].attempt == 2
19341986
assert result["context"].metadata["recovered_after_submit_error"] is True
19351987
assert hooks.prepare_run_arguments_calls == 1
19361988
assert len(client.created) == 1
19371989
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]
1990+
assert sleeps == [*pipeline_run_manager._SUBMIT_RECOVERY_BACKOFF_SECONDS, 0.5]
19391991
assert events == [("before_retry", 2, None), ("after_retry_submit", 2, "run-created")]
19401992

19411993

uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)