|
| 1 | +""" |
| 2 | +Interactive callback handler for durable function executions. |
| 3 | +""" |
| 4 | + |
| 5 | +import logging |
| 6 | +from typing import Optional |
| 7 | + |
| 8 | +import click |
| 9 | + |
| 10 | +from samcli.lib.clients.lambda_client import DurableFunctionsClient |
| 11 | + |
| 12 | +LOG = logging.getLogger(__name__) |
| 13 | + |
| 14 | +# Menu choice constants |
| 15 | +CHOICE_SUCCESS = 1 |
| 16 | +CHOICE_FAILURE = 2 |
| 17 | +CHOICE_HEARTBEAT = 3 |
| 18 | +CHOICE_STOP = 4 |
| 19 | + |
| 20 | + |
| 21 | +class DurableCallbackHandler: |
| 22 | + """ |
| 23 | + Handles interactive callback detection and response for durable executions. |
| 24 | + """ |
| 25 | + |
| 26 | + def __init__(self, client: DurableFunctionsClient): |
| 27 | + self.client = client |
| 28 | + self._prompted_callbacks: set[str] = set() # Track which callbacks we've already prompted for |
| 29 | + |
| 30 | + def check_for_pending_callbacks(self, execution_arn: str) -> Optional[str]: |
| 31 | + """ |
| 32 | + Check execution history for pending callbacks. |
| 33 | +
|
| 34 | + Returns: |
| 35 | + callback_id if found, None otherwise |
| 36 | + """ |
| 37 | + try: |
| 38 | + LOG.debug("Checking for pending callbacks in execution: %s", execution_arn) |
| 39 | + history = self.client.get_durable_execution_history(execution_arn) |
| 40 | + events = history.get("Events", []) |
| 41 | + |
| 42 | + if events: |
| 43 | + callback_states = {} |
| 44 | + |
| 45 | + for event in events: |
| 46 | + event_type = event.get("EventType") |
| 47 | + event_id = event.get("Id") |
| 48 | + |
| 49 | + if event_type == "CallbackStarted": |
| 50 | + callback_id = event.get("CallbackStartedDetails", {}).get("CallbackId") |
| 51 | + callback_states[event_id] = {"callback_id": callback_id, "status": "STARTED", "event": event} |
| 52 | + elif event_type in ["CallbackCompleted", "CallbackFailed", "CallbackSucceeded"]: |
| 53 | + if event_id in callback_states: |
| 54 | + callback_states[event_id]["status"] = "COMPLETED" |
| 55 | + |
| 56 | + # Find callbacks that are started but not completed |
| 57 | + for callback_id, state in callback_states.items(): |
| 58 | + if state["status"] == "STARTED" and state["callback_id"]: |
| 59 | + return str(state["callback_id"]) |
| 60 | + |
| 61 | + except Exception as e: |
| 62 | + LOG.error("Failed to check callback history: %s", e) |
| 63 | + |
| 64 | + return None |
| 65 | + |
| 66 | + def prompt_callback_response(self, execution_arn: str, callback_id: str, execution_complete=None) -> bool: |
| 67 | + """ |
| 68 | + Prompt user for callback response and send it. |
| 69 | +
|
| 70 | + Args: |
| 71 | + execution_arn: The execution ARN for stop execution operation |
| 72 | + callback_id: The callback ID to respond to |
| 73 | + execution_complete: Optional threading.Event to check if execution finished |
| 74 | +
|
| 75 | + Returns: |
| 76 | + True if callback was sent, False if user chose to continue waiting |
| 77 | + """ |
| 78 | + # Only prompt once per callback ID to avoid blocking on timed-out callbacks |
| 79 | + if callback_id in self._prompted_callbacks: |
| 80 | + return False |
| 81 | + |
| 82 | + self._prompted_callbacks.add(callback_id) |
| 83 | + |
| 84 | + # Check if execution already completed before prompting |
| 85 | + if execution_complete and execution_complete.is_set(): |
| 86 | + return False |
| 87 | + |
| 88 | + click.echo(f"\n🔄 Execution is waiting for callback: {callback_id}") |
| 89 | + click.echo("Choose an action:") |
| 90 | + click.echo(" 1. Send callback success") |
| 91 | + click.echo(" 2. Send callback failure") |
| 92 | + click.echo(" 3. Send callback heartbeat") |
| 93 | + click.echo(" 4. Stop execution") |
| 94 | + |
| 95 | + choice = click.prompt("Enter choice", type=click.IntRange(1, 4), default=CHOICE_SUCCESS) |
| 96 | + |
| 97 | + # Check again after user makes selection in case execution completed |
| 98 | + if execution_complete and execution_complete.is_set(): |
| 99 | + click.echo("⚠️ Execution already completed, callback no longer needed") |
| 100 | + return False |
| 101 | + |
| 102 | + try: |
| 103 | + if choice == CHOICE_SUCCESS: |
| 104 | + result = click.prompt("Enter success result (optional)", default="", show_default=False) |
| 105 | + self.client.send_callback_success(callback_id=callback_id, result=result) |
| 106 | + click.echo("✅ Callback success sent") |
| 107 | + return True |
| 108 | + |
| 109 | + elif choice == CHOICE_FAILURE: |
| 110 | + error_message = click.prompt("Enter error message", default="User cancelled") |
| 111 | + error_type = click.prompt("Enter error type (optional)", default="", show_default=False) or None |
| 112 | + |
| 113 | + self.client.send_callback_failure( |
| 114 | + callback_id=callback_id, error_message=error_message, error_type=error_type |
| 115 | + ) |
| 116 | + click.echo("❌ Callback failure sent") |
| 117 | + return True |
| 118 | + |
| 119 | + elif choice == CHOICE_HEARTBEAT: |
| 120 | + self.client.send_callback_heartbeat(callback_id=callback_id) |
| 121 | + click.echo("💓 Callback heartbeat sent") |
| 122 | + return False # Continue waiting after heartbeat |
| 123 | + |
| 124 | + else: # CHOICE_STOP |
| 125 | + error_message = click.prompt("Enter error message", default="Execution stopped by user") |
| 126 | + error_type = click.prompt("Enter error type (optional)", default="", show_default=False) or None |
| 127 | + |
| 128 | + self.client.stop_durable_execution( |
| 129 | + durable_execution_arn=execution_arn, error_message=error_message, error_type=error_type |
| 130 | + ) |
| 131 | + click.echo("🛑 Execution stopped") |
| 132 | + return True |
| 133 | + |
| 134 | + except Exception as e: |
| 135 | + LOG.error("Failed to send callback: %s", e) |
| 136 | + click.echo(f"❌ Failed to send callback: {e}") |
| 137 | + return False |
0 commit comments