1fanwang opened a new pull request, #72414:
URL: https://github.com/apache/airflow/pull/72414

   A synchronous Airbyte task can crash after Airbyte accepts a sync, then 
start a duplicate sync when Airflow retries it. With this change, retries 
resume active Airbyte jobs and recover completed jobs. A new job is created 
only when no durable state exists or the previous job failed or was cancelled.
   
   The operator uses AIP-103 `ResumableJobMixin` only for the synchronous, 
non-deferrable wait path. Recovery restores `job_id` before polling or 
returning so task results, the Airbyte job link, and `on_kill()` keep pointing 
at the recovered job. Asynchronous and deferrable execution are unchanged. 
Airflow versions before 3.3 retain the existing submit behavior.
   
   ## Testing
   
   | Scenario | Result |
   | --- | --- |
   | Provider behavior matrix | Sync recovery, async/deferrable isolation, 
`on_kill()`, and pre-3.3 compatibility covered |
   | Active job stored before retry | Reconnected to job 77 without submitting |
   | Current `upstream/main` with the same stored job | Submitted duplicate job 
101 |
   | Airflow 2.11 compatibility probe | Existing submit behavior preserved |
   
   <details>
   <summary>Commands and raw logs</summary>
   
   ```console
   $ AIRFLOW_HOME=/tmp/airbyte-tests .venv/bin/uv run --project 
providers/airbyte pytest providers/airbyte/tests/unit/airbyte -q
   collected 64 items
   ...
   =================== 64 passed, 1 warning in 71.13s (0:01:11) 
===================
   ```
   
   The recovery probe calls the real operator `execute()` path with a 
task-state store containing `airbyte_job_id=77`. A local stand-in `AirbyteHook` 
reports that job as running and records submissions, status checks, and waits.
   
   ```console
   $ REPRO_VARIANT=upstream/main \
     AIRBYTE_PROVIDER_SOURCE_ROOT=$UPSTREAM_MAIN/providers/airbyte/src \
     .venv/bin/uv run --project providers/airbyte python 
airbyte_resumable_repro.py
   {'variant': 'upstream/main', 'stored_before': 77, 'task_state_get_calls': 
[], 'task_state_set_calls': [], 'submit_count': 1, 'status_calls': [], 
'wait_calls': [101], 'result': 101, 'operator_job_id': 101}
   Traceback (most recent call last):
     File "airbyte_resumable_repro.py", line 90, in <module>
       raise RuntimeError("operator did not reconnect to the stored active job")
   RuntimeError: operator did not reconnect to the stored active job
   
   $ REPRO_VARIANT="PR branch" \
     AIRBYTE_PROVIDER_SOURCE_ROOT=$PWD/providers/airbyte/src \
     .venv/bin/uv run --project providers/airbyte python 
airbyte_resumable_repro.py
   2026-09-02T05:01:37.864774Z [info     ] Reconnecting to existing job   
[airflow.task.operators.airflow.providers.airbyte.operators.airbyte.AirbyteTriggerSyncOperator]
 external_id=77 external_id_key=airbyte_job_id loc=resumablejobmixin.py:161 
status=running
   {'variant': 'PR branch', 'stored_before': 77, 'task_state_get_calls': 
['airbyte_job_id'], 'task_state_set_calls': [], 'submit_count': 0, 
'status_calls': [77], 'wait_calls': [77], 'result': 77, 'operator_job_id': 77}
   ```
   
   </details>
   
   <details>
   <summary>Stand-in hook reproducer</summary>
   
   Save this as `airbyte_resumable_repro.py` before running the commands above.
   
   ```python
   from __future__ import annotations
   
   import os
   import sys
   from importlib import import_module, invalidate_caches
   from pathlib import Path
   from types import SimpleNamespace
   from typing import Any
   
   from airbyte_api.models import JobStatusEnum
   
   import airflow.providers
   
   if source_root := os.environ.get("AIRBYTE_PROVIDER_SOURCE_ROOT"):
       airflow.providers.__path__.insert(0, str(Path(source_root) / "airflow" / 
"providers"))
       for module_name in tuple(sys.modules):
           if module_name == "airflow.providers.airbyte" or 
module_name.startswith(
               "airflow.providers.airbyte."
           ):
               del sys.modules[module_name]
       invalidate_caches()
   
   airbyte_module = import_module("airflow.providers.airbyte.operators.airbyte")
   
   
   class TaskStateStore:
       def __init__(self) -> None:
           self.values: dict[str, int] = {"airbyte_job_id": 77}
           self.get_calls: list[str] = []
           self.set_calls: list[tuple[str, int]] = []
   
       def get(self, key: str) -> int | None:
           self.get_calls.append(key)
           return self.values.get(key)
   
       def set(self, key: str, value: int) -> None:
           self.set_calls.append((key, value))
           self.values[key] = value
   
   
   class StandInAirbyteHook:
       def __init__(self) -> None:
           self.submit_count = 0
           self.status_calls: list[int] = []
           self.wait_calls: list[int] = []
   
       def submit_sync_connection(self, *, connection_id: str) -> Any:
           self.submit_count += 1
           return SimpleNamespace(job_id=101, status=JobStatusEnum.RUNNING)
   
       def get_job_status(self, *, job_id: int) -> str:
           self.status_calls.append(job_id)
           return JobStatusEnum.RUNNING
   
       def wait_for_job(self, *, job_id: int, wait_seconds: float, timeout: 
float) -> None:
           self.wait_calls.append(job_id)
   
   
   task_store = TaskStateStore()
   hook = StandInAirbyteHook()
   airbyte_module.AirbyteHook = lambda **kwargs: hook
   operator = airbyte_module.AirbyteTriggerSyncOperator(
       task_id="airbyte_live_recovery",
       connection_id="connection-uuid",
       deferrable=False,
       asynchronous=False,
       wait_seconds=0,
   )
   result = operator.execute(context={"task_state_store": task_store})
   print(
       {
           "variant": os.environ["REPRO_VARIANT"],
           "stored_before": 77,
           "task_state_get_calls": task_store.get_calls,
           "task_state_set_calls": task_store.set_calls,
           "submit_count": hook.submit_count,
           "status_calls": hook.status_calls,
           "wait_calls": hook.wait_calls,
           "result": result,
           "operator_job_id": operator.job_id,
       }
   )
   
   if (
       hook.submit_count != 0
       or hook.status_calls != [77]
       or hook.wait_calls != [77]
       or result != 77
       or operator.job_id != 77
   ):
       raise RuntimeError("operator did not reconnect to the stored active job")
   ```
   
   </details>
   
   ---
   
   ##### Was generative AI tooling used to co-author this PR?
   
   - [X] Yes (GitHub Copilot CLI, GPT-5.6 Sol)
   
   Generated-by: GitHub Copilot CLI (GPT-5.6 Sol) following [the 
guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions)
   
   ---
   
   * Read the **[Pull Request 
Guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#pull-request-guidelines)**
 for more information. Note: commit author/co-author name and email in commits 
become permanently public when merged.
   * For fundamental code changes, an Airflow Improvement Proposal 
([AIP](https://cwiki.apache.org/confluence/display/AIRFLOW/Airflow+Improvement+Proposals))
 is needed.
   * When adding dependency, check compliance with the [ASF 3rd Party License 
Policy](https://www.apache.org/legal/resolved.html#category-x).
   * For significant user-facing changes create newsfragment: 
`{pr_number}.significant.rst`, in 
[airflow-core/newsfragments](https://github.com/apache/airflow/tree/main/airflow-core/newsfragments).
 You can add this file in a follow-up commit after the PR is created so you 
know the PR number.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to