Skip to content

Commit 6307c62

Browse files
committed
fix(tangle-cli): recover submitted runs after timeout
Assisted-By: devx/0c001c3a-79d6-4fd4-9ba8-4012e253d1e2
1 parent 70fef85 commit 6307c62

3 files changed

Lines changed: 363 additions & 14 deletions

File tree

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

Lines changed: 208 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import json
1515
import re
1616
import time
17+
import uuid
1718
from collections.abc import Callable
1819
from contextlib import AbstractContextManager, nullcontext
1920
from dataclasses import dataclass, field
@@ -35,6 +36,9 @@
3536
_FAILURE_EARLY_EXIT_STATUSES = ("FAILED", "SYSTEM_ERROR")
3637
_EXECUTION_STATE_TIMINGS_METADATA_KEY = "execution_state_timings"
3738
_EXECUTION_STATE_TIMING_MONOTONIC_METADATA_KEY = "_execution_state_timing_monotonic"
39+
_SUBMISSION_ID_ANNOTATION_KEY = "tangle-cli/submission-id"
40+
_SUBMIT_RECOVERY_LOOKUP_ATTEMPTS = 2
41+
_SUBMIT_RECOVERY_LOOKUP_DELAY_SECONDS = 0.1
3842

3943

4044
class PipelineRunError(RuntimeError):
@@ -45,6 +49,10 @@ class UnsupportedPipelineRunFeatureError(PipelineRunError):
4549
"""Raised for TD extension points intentionally unsupported in OSS defaults."""
4650

4751

52+
class AmbiguousPipelineRunRecoveryError(PipelineRunError):
53+
"""Raised when submit recovery finds multiple runs for one submission id."""
54+
55+
4856
@dataclass
4957
class PipelineSubmitPayload:
5058
"""Prepared submit payload state before calling ``pipeline_runs_create``.
@@ -1497,6 +1505,132 @@ def _wait_result(
14971505
result["early_exit"] = True
14981506
return result
14991507

1508+
@staticmethod
1509+
def _ensure_submission_id_annotation(body: dict[str, Any]) -> str:
1510+
annotations = body.setdefault("annotations", {})
1511+
if not isinstance(annotations, dict):
1512+
annotations = {}
1513+
body["annotations"] = annotations
1514+
submission_id = annotations.get(_SUBMISSION_ID_ANNOTATION_KEY)
1515+
if submission_id:
1516+
annotations[_SUBMISSION_ID_ANNOTATION_KEY] = str(submission_id)
1517+
return str(submission_id)
1518+
submission_id = uuid.uuid4().hex
1519+
annotations[_SUBMISSION_ID_ANNOTATION_KEY] = submission_id
1520+
return submission_id
1521+
1522+
@staticmethod
1523+
def _submission_id_from_body(body: Mapping[str, Any]) -> str | None:
1524+
annotations = body.get("annotations")
1525+
if not isinstance(annotations, Mapping):
1526+
return None
1527+
submission_id = annotations.get(_SUBMISSION_ID_ANNOTATION_KEY)
1528+
return str(submission_id) if submission_id else None
1529+
1530+
def _submitted_runs_for_submission_id(self, submission_id: str) -> list[dict[str, Any]]:
1531+
query = {
1532+
"and": [
1533+
PipelineRunSearch.build_value_equals(
1534+
key=_SUBMISSION_ID_ANNOTATION_KEY,
1535+
value=submission_id,
1536+
)
1537+
]
1538+
}
1539+
response = self._require_client().pipeline_runs_list(
1540+
filter_query=json.dumps(query, separators=(",", ":")),
1541+
include_pipeline_names=True,
1542+
)
1543+
plain = self.to_plain(response)
1544+
if not isinstance(plain, Mapping):
1545+
return []
1546+
runs = plain.get("pipeline_runs")
1547+
if not isinstance(runs, list):
1548+
return []
1549+
return [dict(run) for run in runs if isinstance(run, Mapping)]
1550+
1551+
def _recover_submitted_run_after_submit_error(
1552+
self,
1553+
*,
1554+
submission_id: str | None,
1555+
) -> dict[str, Any] | None:
1556+
if not submission_id:
1557+
return None
1558+
for lookup_attempt in range(1, _SUBMIT_RECOVERY_LOOKUP_ATTEMPTS + 1):
1559+
self.logger.info(
1560+
"Checking whether failed submit already created a pipeline run "
1561+
f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}, "
1562+
f"lookup_attempt={lookup_attempt}/{_SUBMIT_RECOVERY_LOOKUP_ATTEMPTS})"
1563+
)
1564+
try:
1565+
matches = self._submitted_runs_for_submission_id(submission_id)
1566+
except Exception as exc:
1567+
self.logger.warn(
1568+
"Submit recovery lookup failed "
1569+
f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}): {exc}. "
1570+
"Falling back to resubmitting the same frozen body."
1571+
)
1572+
return None
1573+
self.logger.info(
1574+
"Submit recovery lookup matched "
1575+
f"{len(matches)} run(s) for {_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}"
1576+
)
1577+
if len(matches) == 1:
1578+
run = matches[0]
1579+
run_id = run.get("id")
1580+
root_execution_id = run.get("root_execution_id")
1581+
self.logger.info(
1582+
"Recovered existing pipeline run "
1583+
f"run_id={run_id}, root_execution_id={root_execution_id}, "
1584+
f"{_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}; adopting instead of resubmitting."
1585+
)
1586+
return run
1587+
if len(matches) > 1:
1588+
run_ids = [str(run.get("id")) for run in matches if run.get("id") is not None]
1589+
self.logger.warn(
1590+
"Submit recovery lookup was ambiguous "
1591+
f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}, matched_run_ids={run_ids}). "
1592+
"Refusing to submit a duplicate."
1593+
)
1594+
raise AmbiguousPipelineRunRecoveryError(
1595+
"Found multiple pipeline runs for failed submit recovery "
1596+
f"{_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}: {', '.join(run_ids) or matches!r}. "
1597+
"Refusing to submit a duplicate."
1598+
)
1599+
if lookup_attempt < _SUBMIT_RECOVERY_LOOKUP_ATTEMPTS:
1600+
time.sleep(_SUBMIT_RECOVERY_LOOKUP_DELAY_SECONDS)
1601+
self.logger.warn(
1602+
"No existing pipeline run found after submit failure "
1603+
f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id}); "
1604+
"resubmitting the same frozen body with preserved inputs."
1605+
)
1606+
return None
1607+
1608+
def _adopt_submitted_run(
1609+
self,
1610+
*,
1611+
response: Mapping[str, Any],
1612+
body: dict[str, Any],
1613+
pipeline_path: str | Path | None,
1614+
attempt: int,
1615+
context: PipelineRunContext,
1616+
) -> dict[str, Any]:
1617+
response_dict = dict(response)
1618+
submitted_context = self.response_run_context(
1619+
response_dict,
1620+
submit_body=body,
1621+
pipeline_path=pipeline_path,
1622+
attempt=attempt,
1623+
)
1624+
context.run_id = submitted_context.run_id
1625+
context.run_name = submitted_context.run_name
1626+
context.root_execution_id = submitted_context.root_execution_id
1627+
context.submit_body = submitted_context.submit_body
1628+
context.pipeline_spec = submitted_context.pipeline_spec
1629+
context.response = response_dict
1630+
context.metadata["recovered_after_submit_error"] = True
1631+
self.hooks.after_submit_context(context)
1632+
return response_dict
1633+
15001634
def _run_body_factory(
15011635
self,
15021636
body_factory: Callable[[int, PipelineRunContext | None, Exception | None], dict[str, Any]],
@@ -1535,8 +1669,34 @@ def _run_body_factory(
15351669
success = False
15361670
error: Exception | None = None
15371671
retry_requested = False
1538-
body = body_factory(attempt, previous_context, last_error)
1672+
reused_after_submit_failure = (
1673+
previous_context is not None
1674+
and previous_context.run_id is None
1675+
and previous_context.submit_body is not None
1676+
)
1677+
if reused_after_submit_failure:
1678+
# The previous attempt failed while submitting, before the API
1679+
# returned a run id. Retry the exact same submit body instead
1680+
# of rebuilding it: body construction can intentionally inject
1681+
# dynamic inputs (for example a scheduler creation timestamp),
1682+
# and changing those inputs on an ambiguous submit timeout can
1683+
# defeat cache reuse or double-run the logical pipeline.
1684+
body = copy.deepcopy(previous_context.submit_body)
1685+
self.logger.info(
1686+
"Retrying submit after submit exception with the same frozen body "
1687+
f"({_SUBMISSION_ID_ANNOTATION_KEY}={self._submission_id_from_body(body)}); "
1688+
"dynamic inputs are preserved."
1689+
)
1690+
else:
1691+
if previous_context is not None:
1692+
self.logger.info(
1693+
"Retrying after pipeline failure; rebuilding submit body so dynamic run arguments "
1694+
"can follow hook policy (for example update-vs-fixed time input)."
1695+
)
1696+
body = body_factory(attempt, previous_context, last_error)
15391697
self.normalize_submit_body_in_place(body)
1698+
submission_id = self._ensure_submission_id_annotation(body)
1699+
context.metadata["submission_id"] = submission_id
15401700
if metadata_factory is not None:
15411701
context.metadata.update(metadata_factory(attempt, previous_context, last_error))
15421702
pipeline_spec = body.get("root_task", {}).get("componentRef", {}).get("spec")
@@ -1557,14 +1717,50 @@ def _run_body_factory(
15571717
try:
15581718
with self.hooks.around_run(context):
15591719
try:
1560-
response = self.submit_prepared_body(
1561-
body,
1562-
pipeline_path=pipeline_path,
1563-
attempt=attempt,
1564-
context=context,
1565-
)
1566-
if attempt > 1:
1567-
self.hooks.after_retry_submit(context)
1720+
recovered_response = None
1721+
if reused_after_submit_failure:
1722+
recovered_response = self._recover_submitted_run_after_submit_error(
1723+
submission_id=self._submission_id_from_body(body),
1724+
)
1725+
if recovered_response is not None:
1726+
response = self._adopt_submitted_run(
1727+
response=recovered_response,
1728+
body=body,
1729+
pipeline_path=pipeline_path,
1730+
attempt=attempt,
1731+
context=context,
1732+
)
1733+
else:
1734+
try:
1735+
response = self.submit_prepared_body(
1736+
body,
1737+
pipeline_path=pipeline_path,
1738+
attempt=attempt,
1739+
context=context,
1740+
)
1741+
except Exception as submit_exc:
1742+
if context.run_id is not None:
1743+
raise
1744+
submission_id_for_recovery = self._submission_id_from_body(body)
1745+
self.logger.warn(
1746+
"Submit failed before a run id was returned "
1747+
f"({_SUBMISSION_ID_ANNOTATION_KEY}={submission_id_for_recovery}): "
1748+
f"{submit_exc}. Checking whether the run was actually created."
1749+
)
1750+
recovered_response = self._recover_submitted_run_after_submit_error(
1751+
submission_id=submission_id_for_recovery,
1752+
)
1753+
if recovered_response is None:
1754+
raise
1755+
response = self._adopt_submitted_run(
1756+
response=recovered_response,
1757+
body=body,
1758+
pipeline_path=pipeline_path,
1759+
attempt=attempt,
1760+
context=context,
1761+
)
1762+
if attempt > 1:
1763+
self.hooks.after_retry_submit(context)
15681764
result: dict[str, Any]
15691765
if wait and context.run_id:
15701766
wait_result = self.wait_for_completion(
@@ -1587,6 +1783,9 @@ def _run_body_factory(
15871783
except Exception as exc:
15881784
error = exc
15891785
last_error = exc
1786+
if isinstance(exc, AmbiguousPipelineRunRecoveryError):
1787+
self.hooks.on_fail_fast_before_release(context, exc)
1788+
raise
15901789
if (
15911790
context.run_id
15921791
and attempt < max_attempts

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -552,11 +552,30 @@ def body_factory(
552552

553553
def metadata_factory(
554554
attempt: int,
555-
_previous_context: PipelineRunContext | None,
555+
previous_context: PipelineRunContext | None,
556556
_error: Exception | None,
557557
) -> dict[str, Any]:
558-
preparation = preparations[attempt]
558+
preparation = preparations.get(attempt)
559559
submit_payload = submit_payloads.get(attempt)
560+
if (
561+
preparation is None
562+
and previous_context is not None
563+
and previous_context.run_id is None
564+
and previous_context.submit_body is not None
565+
):
566+
# ``PipelineRunManager`` reuses the previous submit body after
567+
# submit-time exceptions. Mirror the previous preparation
568+
# bookkeeping so metadata/result formatting still point at the
569+
# logical pipeline being retried without re-running dynamic
570+
# body preparation hooks.
571+
preparation = preparations.get(previous_context.attempt)
572+
if preparation is not None:
573+
preparations[attempt] = preparation
574+
submit_payload = submit_payloads.get(previous_context.attempt)
575+
if submit_payload is not None:
576+
submit_payloads[attempt] = submit_payload
577+
if preparation is None:
578+
raise PipelineRunError("Pipeline retry metadata requested before preparation")
560579
return hooks.metadata_for_run(
561580
pipeline_name=(submit_payload.run_name if submit_payload else None) or preparation.pipeline_name,
562581
pipeline_path=pipeline_path,
@@ -592,5 +611,10 @@ def metadata_factory(
592611
error = exc
593612
raise
594613
finally:
614+
cleaned_preparation_ids: set[int] = set()
595615
for preparation in preparations.values():
616+
preparation_id = id(preparation)
617+
if preparation_id in cleaned_preparation_ids:
618+
continue
619+
cleaned_preparation_ids.add(preparation_id)
596620
hooks.cleanup_prepared_pipeline(preparation, error=error)

0 commit comments

Comments
 (0)