1414import json
1515import re
1616import time
17+ import uuid
1718from collections .abc import Callable
1819from contextlib import AbstractContextManager , nullcontext
1920from dataclasses import dataclass , field
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
4044class 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
4957class 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
0 commit comments