|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import asyncio |
| 4 | +import contextlib |
4 | 5 | import logging |
5 | 6 | from collections.abc import MutableSequence, MutableSet |
6 | 7 | from typing import cast |
7 | 8 |
|
8 | 9 | from streamflow.core.exception import FailureHandlingException |
9 | | -from streamflow.core.recovery import RecoveryPolicy |
| 10 | +from streamflow.core.recovery import RecoveryPolicy, RetryRequest |
10 | 11 | from streamflow.core.utils import get_tag |
11 | | -from streamflow.core.workflow import Job, Step, Token, Workflow |
| 12 | +from streamflow.core.workflow import Job, Port, Step, Token, Workflow |
12 | 13 | from streamflow.log_handler import logger |
13 | 14 | from streamflow.persistence.loading_context import WorkflowBuilder |
14 | | -from streamflow.recovery.utils import ( |
15 | | - GraphMapper, |
16 | | - ProvenanceGraph, |
17 | | - TokenAvailability, |
18 | | - create_graph_mapper, |
19 | | -) |
| 15 | +from streamflow.recovery.utils import GraphMapper, ProvenanceGraph, create_graph_mapper |
20 | 16 | from streamflow.workflow.executor import StreamFlowExecutor |
21 | 17 | from streamflow.workflow.port import ( |
22 | 18 | BoundaryAction, |
|
29 | 25 | from streamflow.workflow.utils import get_job_token |
30 | 26 |
|
31 | 27 |
|
| 28 | +def _get_recovery_port( |
| 29 | + token_id: int, |
| 30 | + mapper: GraphMapper, |
| 31 | + original_workflow: Workflow, |
| 32 | + recovery_workflow: Workflow, |
| 33 | +) -> Port: |
| 34 | + port_name = next( |
| 35 | + curr_port |
| 36 | + for curr_port, curr_tokens in mapper.port_tokens.items() |
| 37 | + if token_id in curr_tokens |
| 38 | + ) |
| 39 | + if port_name not in recovery_workflow.ports.keys() or not isinstance( |
| 40 | + recovery_workflow.ports[port_name], InterWorkflowPort |
| 41 | + ): |
| 42 | + return recovery_workflow.create_port( |
| 43 | + cls=type(original_workflow.ports[port_name]), |
| 44 | + name=port_name, |
| 45 | + ) |
| 46 | + else: |
| 47 | + return recovery_workflow.ports[port_name] |
| 48 | + |
| 49 | + |
32 | 50 | async def _inject_tokens( |
33 | 51 | failed_job: Job, |
34 | 52 | failed_step: Step, |
@@ -124,62 +142,48 @@ async def _populate_workflow( |
124 | 142 |
|
125 | 143 |
|
126 | 144 | class RollbackRecoveryPolicy(RecoveryPolicy): |
127 | | - async def _sync_workflows( |
| 145 | + async def _synchronize_workflows( |
128 | 146 | self, |
129 | | - job_names: MutableSet[str], |
| 147 | + failed_job: str, |
130 | 148 | job_tokens: MutableSequence[Token], |
131 | 149 | mapper: GraphMapper, |
| 150 | + retry_requests: MutableSequence[RetryRequest], |
132 | 151 | workflow: Workflow, |
133 | 152 | ) -> None: |
134 | | - for job_name in job_names: |
135 | | - retry_request = self.context.failure_manager.get_request(job_name) |
136 | | - if ( |
137 | | - is_available := await self.context.failure_manager.is_recovered( |
138 | | - job_name |
139 | | - ) |
140 | | - ) == TokenAvailability.FutureAvailable: |
| 153 | + for retry_request in retry_requests: |
| 154 | + job_name = retry_request.name |
| 155 | + if await self.context.failure_manager.is_recovering(job_name): |
141 | 156 | job_token = get_job_token(job_name, job_tokens) |
142 | | - # The `retry_request` is the current job running, instead |
143 | | - # the `job_token` is the token to remove in the graph because |
144 | | - # the workflow will depend on the already running job |
145 | 157 | if logger.isEnabledFor(logging.DEBUG): |
146 | | - logger.debug(f"Synchronize rollbacks: job {job_name} is running") |
147 | | - # todo: create a unit test for this case |
148 | | - for port_name in await mapper.get_output_ports(job_token): |
149 | | - if port_name in retry_request.workflow.ports.keys(): |
150 | | - cast( |
151 | | - InterWorkflowJobPort, |
152 | | - retry_request.workflow.ports[port_name], |
153 | | - ).add_inter_port( |
154 | | - workflow.create_port( |
155 | | - cls=InterWorkflowJobPort, name=port_name |
156 | | - ), |
157 | | - boundary_tags=[job_token.tag], |
158 | | - boundary_action=( |
159 | | - BoundaryAction.PROPAGATE | BoundaryAction.TERMINATE |
160 | | - ), |
161 | | - ) |
162 | | - # Remove tokens recovered in other workflows |
163 | | - for token_id in await mapper.get_output_tokens(job_token.persistent_id): |
| 158 | + logger.debug( |
| 159 | + f"Synchronizing rollbacks for failed job {failed_job}: Job {job_name} is currently executing." |
| 160 | + ) |
| 161 | + available_tokens = set() |
| 162 | + for token_id in ( |
| 163 | + mapper.dag_tokens.successors(job_token.persistent_id) |
| 164 | + if mapper.dag_tokens.contains(job_token.persistent_id) |
| 165 | + else [] |
| 166 | + ): |
164 | 167 | mapper.move_token_to_root(token_id) |
165 | | - elif is_available == TokenAvailability.Available: |
166 | | - job_token = get_job_token(job_name, job_tokens) |
| 168 | + available_tokens.add(token_id) |
| 169 | + # Some tokens could be discarded by the `move_token_to_root` method |
| 170 | + for token_id in available_tokens & mapper.token_instances.keys(): |
| 171 | + new_port = _get_recovery_port( |
| 172 | + token_id, mapper, retry_request.workflow, workflow |
| 173 | + ) |
| 174 | + cast( |
| 175 | + InterWorkflowPort, |
| 176 | + retry_request.workflow.ports[new_port.name], |
| 177 | + ).add_inter_port( |
| 178 | + port=new_port, |
| 179 | + boundary_tags=[job_token.tag], |
| 180 | + boundary_action=BoundaryAction.PROPAGATE, |
| 181 | + ) |
| 182 | + else: |
167 | 183 | if logger.isEnabledFor(logging.DEBUG): |
168 | 184 | logger.debug( |
169 | | - f"Synchronize rollbacks: job {job_token.value.name} output available" |
| 185 | + f"Synchronizing rollbacks for failed job {failed_job}: Job {job_name} rollback" |
170 | 186 | ) |
171 | | - # Search execute token after job token, replace this token with job_req token. |
172 | | - # Then remove all the prev tokens |
173 | | - for port_name in await mapper.get_output_ports(job_token): |
174 | | - if port_name in retry_request.output_tokens.keys(): |
175 | | - new_token = retry_request.output_tokens[port_name] |
176 | | - mapper.replace_token( |
177 | | - port_name, |
178 | | - new_token, |
179 | | - True, |
180 | | - ) |
181 | | - mapper.move_token_to_root(new_token.persistent_id) |
182 | | - else: |
183 | 187 | await self.context.failure_manager.update_request(job_name) |
184 | 188 | retry_request.workflow = workflow |
185 | 189 |
|
@@ -211,24 +215,35 @@ async def recover(self, failed_job: Job, failed_step: Step) -> None: |
211 | 215 | job_tokens = list( |
212 | 216 | filter(lambda t: isinstance(t, JobToken), mapper.token_instances.values()) |
213 | 217 | ) |
214 | | - await self._sync_workflows( |
215 | | - job_names={*(t.value.name for t in job_tokens), failed_job.name}, |
216 | | - job_tokens=job_tokens, |
217 | | - mapper=mapper, |
218 | | - workflow=new_workflow, |
219 | | - ) |
220 | | - if mapper.dag_tokens.empty(): |
221 | | - raise FailureHandlingException( |
222 | | - f"Impossible to recover {failed_job.name}: empty token graph" |
| 218 | + retry_requests = [ |
| 219 | + self.context.failure_manager.get_request(job_name) |
| 220 | + for job_name in {*(t.value.name for t in job_tokens), failed_job.name} |
| 221 | + ] |
| 222 | + async with contextlib.AsyncExitStack() as exit_stack: |
| 223 | + await asyncio.gather( |
| 224 | + *( |
| 225 | + asyncio.create_task(exit_stack.enter_async_context(request.lock)) |
| 226 | + for request in retry_requests |
| 227 | + ) |
| 228 | + ) |
| 229 | + await self._synchronize_workflows( |
| 230 | + failed_job=failed_job.name, |
| 231 | + job_tokens=job_tokens, |
| 232 | + mapper=mapper, |
| 233 | + retry_requests=retry_requests, |
| 234 | + workflow=new_workflow, |
| 235 | + ) |
| 236 | + if mapper.dag_tokens.empty(): |
| 237 | + raise FailureHandlingException( |
| 238 | + f"Impossible to recover {failed_job.name}: empty token graph" |
| 239 | + ) |
| 240 | + # Populate new workflow |
| 241 | + await _populate_workflow( |
| 242 | + failed_step=failed_step, |
| 243 | + step_ids=await mapper.get_step_ids(failed_step.output_ports.values()), |
| 244 | + workflow=new_workflow, |
| 245 | + workflow_builder=workflow_builder, |
223 | 246 | ) |
224 | | - # Populate new workflow |
225 | | - step_ids = await mapper.get_step_ids(failed_step.output_ports.values()) |
226 | | - await _populate_workflow( |
227 | | - failed_step=failed_step, |
228 | | - step_ids=step_ids, |
229 | | - workflow=new_workflow, |
230 | | - workflow_builder=workflow_builder, |
231 | | - ) |
232 | 247 | await _inject_tokens( |
233 | 248 | failed_job=failed_job, |
234 | 249 | failed_step=failed_step, |
|
0 commit comments