kaxil commented on code in PR #74793:
URL: https://github.com/apache/airflow/pull/74793#discussion_r4401154351


##########
airflow-core/src/airflow/dag_processing/manager.py:
##########
@@ -390,13 +486,21 @@ def before_run(self) -> None:
         gc.freeze()
 
     def after_run(self) -> None:
-        """Tear down state after the parsing loop exits. Default no-op; 
override to customize."""
+        """Tear down state after the parsing loop exits. Default 
implementation; override to customize."""
+        if self.api_client is None:
+            return
+        from airflow.dag_processing.api_client import DagProcessorSecretsComms
+        from airflow.sdk.execution_time import task_runner

Review Comment:
   processor.py already imports from `task_runner` at module level and this 
module imports processor, so this import (and the one at 534) can go at the 
top. The `api_client` imports are lazy on purpose per 
`test_direct_manager_does_not_import_api_client`, so a one-line comment saying 
that would stop someone hoisting them.



##########
airflow-core/src/airflow/dag_processing/manager.py:
##########
@@ -848,6 +985,11 @@ def get_bundle_state(self, bundle_name: str, *, session: 
Session = NEW_SESSION)
 
         Returns ``None`` if the bundle has no database record.
         """
+        if self.api_client is not None:

Review Comment:
   Is this branch reachable? The only caller (line 1145) calls 
`get_bundle_state` only when `catalog is None`, and in API mode line 1129 has 
already `continue`d in that case. The `except httpx2.HTTPError` around 
`catalog.get` at 1147 can't fire either.



##########
airflow-core/src/airflow/dag_processing/manager.py:
##########
@@ -902,157 +1095,229 @@ def _refresh_dag_bundles(self, known_files: dict[str, 
set[DagFileInfo]]):
         if periodic_check_due:
             self._bundles_last_refreshed = now_seconds
 
+        catalog: dict[str, BundleState] | None = None
+        if self.api_client is not None and self._dag_bundles:
+            try:
+                bundles = self.api_client.get_bundles()
+                catalog = {
+                    bundle.name: BundleState(bundle.last_refreshed, 
bundle.version, bundle.revision)
+                    for bundle in bundles
+                }
+                self._bundle_name_to_team_name = {bundle.name: 
bundle.team_name for bundle in bundles}
+            except httpx2.HTTPError:
+                self.log.warning("Unable to read the bundle catalog; retrying 
on a later refresh")
+
         any_refreshed = False
-        active_bundles = {file.bundle_name for file in self._processors if 
file.bundle_version is None}
+        active_bundles = {
+            file.bundle_name
+            for file in self._processors.keys() | 
self._pending_publications.keys()
+            if file.bundle_version is None
+        }
         for bundle in self._dag_bundles:
             if not periodic_check_due and bundle.name not in urgent_bundles:
                 continue
-            # TODO: AIP-66 handle errors in the case of incomplete cloning? 
And test this.
-            #  What if the cloning/refreshing took too long(longer than the 
dag processor timeout)
-            if not bundle.is_initialized:
-                self._invalidate_bundle_parse_source(bundle.name)
+            with self._use_bundle(bundle.name):
+                if catalog is not None and bundle.name not in catalog:
+                    self._discard_unusable_bundle_source(bundle.name)

Review Comment:
   When a bundle is missing from the catalog the API returns, this stops 
parsing it with no log line, and does so again on every check. A one-time 
warning naming the bundle would tell an operator why its Dags stopped updating.



##########
airflow-core/src/airflow/dag_processing/manager.py:
##########
@@ -902,157 +1095,229 @@ def _refresh_dag_bundles(self, known_files: dict[str, 
set[DagFileInfo]]):
         if periodic_check_due:
             self._bundles_last_refreshed = now_seconds
 
+        catalog: dict[str, BundleState] | None = None
+        if self.api_client is not None and self._dag_bundles:
+            try:
+                bundles = self.api_client.get_bundles()
+                catalog = {
+                    bundle.name: BundleState(bundle.last_refreshed, 
bundle.version, bundle.revision)
+                    for bundle in bundles
+                }
+                self._bundle_name_to_team_name = {bundle.name: 
bundle.team_name for bundle in bundles}
+            except httpx2.HTTPError:
+                self.log.warning("Unable to read the bundle catalog; retrying 
on a later refresh")
+
         any_refreshed = False
-        active_bundles = {file.bundle_name for file in self._processors if 
file.bundle_version is None}
+        active_bundles = {
+            file.bundle_name
+            for file in self._processors.keys() | 
self._pending_publications.keys()
+            if file.bundle_version is None
+        }
         for bundle in self._dag_bundles:
             if not periodic_check_due and bundle.name not in urgent_bundles:
                 continue
-            # TODO: AIP-66 handle errors in the case of incomplete cloning? 
And test this.
-            #  What if the cloning/refreshing took too long(longer than the 
dag processor timeout)
-            if not bundle.is_initialized:
-                self._invalidate_bundle_parse_source(bundle.name)
+            with self._use_bundle(bundle.name):
+                if catalog is not None and bundle.name not in catalog:
+                    self._discard_unusable_bundle_source(bundle.name)
+                    continue
+                if bundle.name in self._pending_inventories:
+                    # A listing still waiting for the API must be published 
before the bundle is listed again.
+                    if bundle.name in due_inventories:
+                        self._publish_bundle_inventory(bundle.name, 
known_files)
+                        any_refreshed = True
+                    continue
+                if self.api_client is not None and catalog is None:
+                    continue
+                # TODO: AIP-66 handle errors in the case of incomplete 
cloning? And test this.
+                #  What if the cloning/refreshing took too long(longer than 
the dag processor timeout)
+                if not bundle.is_initialized:
+                    self._invalidate_bundle_parse_source(bundle.name)
+                    try:
+                        bundle.initialize()
+                        any_refreshed = True
+                    except Exception as e:
+                        self.log.exception("Error initializing bundle %s: %s", 
bundle.name, e)
+                        continue
                 try:
-                    bundle.initialize()
-                    any_refreshed = True
-                except Exception as e:
-                    self.log.exception("Error initializing bundle %s: %s", 
bundle.name, e)
+                    bundle_state = (
+                        catalog.get(bundle.name)
+                        if catalog is not None
+                        else self.get_bundle_state(bundle.name)
+                    )
+                except httpx2.HTTPError:
+                    self.log.warning("Unable to read metadata for bundle %s; 
retrying later", bundle.name)
+                    continue
+                except Exception:
+                    self.log.exception("Error fetching state for bundle %s", 
bundle.name)
+                    continue
+                if bundle_state is None:
+                    self.log.warning("Bundle model not found for %s", 
bundle.name)
+                    continue
+                elapsed_time_since_refresh = (
+                    now - (bundle_state.last_refreshed or utc_epoch())
+                ).total_seconds()
+                if bundle.supports_versioning:
+                    # we will also check the version of the bundle to see if 
another DAG processor has seen
+                    # a new version
+                    pre_refresh_version = 
self._bundle_versions.get(bundle.name)
+                    # Use `is None` (not falsy) so an empty-string version is 
treated as a valid cached value.
+                    if pre_refresh_version is None:
+                        pre_refresh_version, _ = 
unpack_bundle_version(bundle.get_current_version(), bundle)
+                    current_version_matches_db = pre_refresh_version == 
bundle_state.version
+                else:
+                    # With no versioning, it always "matches"
+                    current_version_matches_db = True
+
+                previously_seen = bundle.name in self._bundle_versions
+                if (
+                    bundle.name not in self._bundles_waiting_for_refresh
+                    and bundle.name not in self._bundles_needing_refresh
+                    and bundle.name in self._bundle_parse_sources
+                    and self.should_skip_refresh(
+                        bundle=bundle,
+                        elapsed_time_since_refresh=elapsed_time_since_refresh,
+                        current_version_matches_db=current_version_matches_db,
+                        previously_seen=previously_seen,
+                    )
+                ):
+                    self.log.debug("Not time to refresh bundle %s", 
bundle.name)
                     continue
-            try:
-                bundle_state = self.get_bundle_state(bundle.name)
-            except Exception:
-                self.log.exception("Error fetching state for bundle %s", 
bundle.name)
-                continue
-            if bundle_state is None:
-                self.log.warning("Bundle model not found for %s", bundle.name)
-                continue
-            elapsed_time_since_refresh = (now - (bundle_state.last_refreshed 
or utc_epoch())).total_seconds()
-            if bundle.supports_versioning:
-                # we will also check the version of the bundle to see if 
another DAG processor has seen
-                # a new version
-                pre_refresh_version = self._bundle_versions.get(bundle.name)
-                # Use `is None` (not falsy) so an empty-string version is 
treated as a valid cached value.
-                if pre_refresh_version is None:
-                    pre_refresh_version, _ = 
unpack_bundle_version(bundle.get_current_version(), bundle)
-                current_version_matches_db = pre_refresh_version == 
bundle_state.version
-            else:
-                # With no versioning, it always "matches"
-                current_version_matches_db = True
-
-            previously_seen = bundle.name in self._bundle_versions
-            if (
-                bundle.name not in self._bundles_waiting_for_refresh
-                and bundle.name not in self._bundles_needing_refresh
-                and bundle.name in self._bundle_parse_sources
-                and self.should_skip_refresh(
-                    bundle=bundle,
-                    elapsed_time_since_refresh=elapsed_time_since_refresh,
-                    current_version_matches_db=current_version_matches_db,
-                    previously_seen=previously_seen,
-                )
-            ):
-                self.log.debug("Not time to refresh bundle %s", bundle.name)
-                continue
 
-            if bundle.refresh_changes_source and bundle.name in active_bundles:
-                # Forced refreshes wait too: refreshing under an import would 
discard its result.
-                self._bundles_waiting_for_refresh.add(bundle.name)
-                continue
+                if bundle.refresh_changes_source and bundle.name in 
active_bundles:
+                    # Forced refreshes wait too: refreshing under an import 
would discard its result.
+                    self._bundles_waiting_for_refresh.add(bundle.name)
+                    continue
 
-            self._bundles_waiting_for_refresh.discard(bundle.name)
-            self.log.info("Refreshing bundle %s", bundle.name)
+                self._bundles_waiting_for_refresh.discard(bundle.name)
+                self.log.info("Refreshing bundle %s", bundle.name)
 
-            previous_source = self._bundle_parse_sources.get(bundle.name)
-            if bundle.refresh_changes_source:
-                self._invalidate_bundle_parse_source(bundle.name)
-            try:
-                bundle.refresh()
-                any_refreshed = True
-            except Exception as error:
-                self.log.exception("Error refreshing bundle %s", bundle.name)
-                source_unchanged = not bundle.refresh_changes_source or (
-                    isinstance(error, DagBundleRefreshError) and 
error.source_unchanged
-                )
-                if source_unchanged and previous_source is not None:
-                    self._bundle_parse_sources[bundle.name] = attrs.evolve(
-                        previous_source,
-                        
refresh_generation=self._bundle_refresh_generations[bundle.name],
+                previous_source = self._bundle_parse_sources.get(bundle.name)
+                if bundle.refresh_changes_source:
+                    self._invalidate_bundle_parse_source(bundle.name)
+                try:
+                    bundle.refresh()
+                    any_refreshed = True
+                except Exception as error:
+                    self.log.exception("Error refreshing bundle %s", 
bundle.name)
+                    source_unchanged = not bundle.refresh_changes_source or (
+                        isinstance(error, DagBundleRefreshError) and 
error.source_unchanged
                     )
-                    self._force_refresh_bundles.discard(bundle.name)
-                    if bundle.name not in self._bundles_needing_refresh:
+                    if source_unchanged and previous_source is not None:
+                        self._bundle_parse_sources[bundle.name] = attrs.evolve(
+                            previous_source,
+                            
refresh_generation=self._bundle_refresh_generations[bundle.name],
+                        )
+                        self._force_refresh_bundles.discard(bundle.name)
+                        if bundle.name not in self._bundles_needing_refresh:
+                            self.log.warning(
+                                "Refresh failed without changing the source; 
continuing to parse bundle",
+                                bundle_name=bundle.name,
+                            )
+                    elif previous_source is not None or bundle.name not in 
self._bundles_needing_refresh:
                         self.log.warning(
-                            "Refresh failed without changing the source; 
continuing to parse bundle",
-                            bundle_name=bundle.name,
+                            "Parsing paused until bundle refresh succeeds", 
bundle_name=bundle.name
                         )
-                elif previous_source is not None or bundle.name not in 
self._bundles_needing_refresh:
-                    self.log.warning("Parsing paused until bundle refresh 
succeeds", bundle_name=bundle.name)
-                self._bundles_needing_refresh.add(bundle.name)
-                continue
+                    self._bundles_needing_refresh.add(bundle.name)
+                    continue
+
+                self._bundles_needing_refresh.discard(bundle.name)
+                self._force_refresh_bundles.discard(bundle.name)
 
-            self._bundles_needing_refresh.discard(bundle.name)
-            self._force_refresh_bundles.discard(bundle.name)
+                if bundle.supports_versioning:
+                    version_after_refresh, version_data_after_refresh = 
unpack_bundle_version(
+                        bundle.get_current_version(), bundle
+                    )
+                else:
+                    version_after_refresh = None
+                    version_data_after_refresh = None
 
-            if bundle.supports_versioning:
-                version_after_refresh, version_data_after_refresh = 
unpack_bundle_version(
-                    bundle.get_current_version(), bundle
+                self._bundle_parse_sources[bundle.name] = DagParseSource(
+                    bundle_version=version_after_refresh,
+                    version_data=deepcopy(version_data_after_refresh),
+                    
refresh_generation=self._bundle_refresh_generations[bundle.name],
                 )
-            else:
-                version_after_refresh = None
-                version_data_after_refresh = None
 
-            self._bundle_parse_sources[bundle.name] = DagParseSource(
-                bundle_version=version_after_refresh,
-                version_data=deepcopy(version_data_after_refresh),
-                
refresh_generation=self._bundle_refresh_generations[bundle.name],
-            )
+                if bundle.supports_versioning:
+                    # We can short-circuit the rest of this if (1) bundle was 
seen before by
+                    # this dag processor and (2) the version of the bundle did 
not change
+                    # after refreshing it
+                    if (
+                        self.api_client is None
+                        and previously_seen
+                        and pre_refresh_version == version_after_refresh
+                    ):
+                        self.log.debug(
+                            "Bundle %s version not changed after refresh: %s",
+                            bundle.name,
+                            version_after_refresh,
+                        )
+                        try:
+                            self.update_bundle_state(bundle.name, 
last_refreshed=now, version=None)
+                        except Exception:
+                            self.log.exception("Error persisting state for 
bundle %s", bundle.name)
+                        continue
 
-            if bundle.supports_versioning:
-                # We can short-circuit the rest of this if (1) bundle was seen 
before by
-                # this dag processor and (2) the version of the bundle did not 
change
-                # after refreshing it
-                if previously_seen and pre_refresh_version == 
version_after_refresh:
-                    self.log.debug(
-                        "Bundle %s version not changed after refresh: %s",
-                        bundle.name,
-                        version_after_refresh,
+                    self.log.info(
+                        "Version changed for %s, new version: %s", 
bundle.name, version_after_refresh
                     )
-                    try:
-                        self.update_bundle_state(bundle.name, 
last_refreshed=now, version=None)
-                    except Exception:
-                        self.log.exception("Error persisting state for bundle 
%s", bundle.name)
+                try:
+                    discovery = self._find_files_in_bundle(bundle)
+                except Exception:
+                    # Keep the bundle's known files and Dags, and leave its 
version unadvanced so the
+                    # next refresh lists it again.
+                    self.log.exception("Error listing Dag definitions in 
bundle %s", bundle.name)
+                    if self.api_client is not None:
+                        self._bundle_parse_sources.pop(bundle.name, None)
                     continue
 
-                self.log.info("Version changed for %s, new version: %s", 
bundle.name, version_after_refresh)
-            try:
-                discovery = self._find_files_in_bundle(bundle)
-            except Exception:
-                # Keep the bundle's known files and Dags, and leave its 
version unadvanced so the
-                # next refresh lists it again.
-                self.log.exception("Error listing Dag definitions in bundle 
%s", bundle.name)
-                continue
+                found_files = discovery.files
+                if self.api_client is not None:
+                    self._dispatch_sequence += 1
+                    try:
+                        inventory = DagBundleInventoryBody(
+                            attempt_id=uuid7(),
+                            dispatch_sequence=self._dispatch_sequence,
+                            expected_revision=bundle_state.revision,
+                            version=version_after_refresh,
+                            
files=sorted(self._get_observed_filelocs(found_files)),
+                            failed_sources=sorted(discovery.failed_sources),
+                        )
+                    except ValidationError:
+                        # Handled like a failed listing, so the API never sees 
a partial inventory.
+                        self.log.exception("Bundle %s has a listing the API 
cannot accept", bundle.name)
+                        self._discard_unusable_bundle_source(bundle.name)

Review Comment:
   One path the inventory model rejects (a backslash in a filename, which is 
legal on Linux, or a path over 2000 characters) takes the whole bundle out 
here, and so does a 413 at line 1049 from a proxy whose body limit is below 16 
MiB. With the parse source popped, `should_skip_refresh` is never consulted, so 
every `bundle_refresh_check_interval` (5s by default) re-runs 
`bundle.refresh()` (a git fetch for Git bundles), lists again and fails again, 
while every file in the bundle waits for a source. DB mode would just parse the 
rest of the bundle. Could the unrepresentable paths be dropped and logged by 
name instead, and a 413 back off through `next_attempt_time`? The 413 branch 
also logs the same "rejected" line as a 409, so including the status there 
would help.



##########
airflow-core/src/airflow/dag_processing/manager.py:
##########
@@ -448,6 +574,8 @@ def load_dag_bundles(self) -> None:
 
     def _scan_stale_dags(self):
         """Scan and deactivate DAGs which are no longer present in files."""
+        if self.api_client is not None:

Review Comment:
   With this early return, what ages out the Dags of a file whose parse now 
times out or exits without a result? Neither path publishes anything in API 
mode (test_manager_api_results.py asserts no publish for the `failed` case), 
and the server only marks earlier definitions stale when it gets an import 
error. A file that starts hanging keeps its last good Dags scheduled with no 
import error shown. Could those two paths send an import error through 
`_build_rejection_report`, so the server's cutoff applies like 
`stale_dag_threshold` does in DB mode?



##########
airflow-core/tests/unit/dag_processing/test_manager.py:
##########
@@ -5673,6 +5742,275 @@ def 
test_a_sweep_pays_the_fixed_cost_once_per_call(self, session, testing_dag_bu
             f"({SWEEP_CALLS} x {fixed} fixed + {SWEEP_FILES} x {per_dag} per 
Dag)."
         )
 
+    @pytest.mark.parametrize("publication_fails", [False, True])
+    @mock.patch.object(DagFileProcessorManager, "persist_parsing_result", 
autospec=True)
+    @mock.patch.object(DagFileProcessorManager, "_create_process", 
autospec=True)
+    def test_api_publication_uses_dispatch_identity_without_local_persistence(
+        self, create, persist, session, publication_fails
+    ):
+        client = mock.create_autospec(DagProcessorAPIClient, instance=True)
+        manager = DagFileProcessorManager(max_runs=1, api_client=client)
+        manager._bundle_parse_sources["testing"] = 
DagParseSource(bundle_version="v1")
+        file = _get_file_infos(["dag.py"])[0]
+        manager._file_queue[file] = None
+        proc, read_end = self.mock_processor()
+        proc.had_callbacks = False
+        proc.parsing_result = DagFileParsingResult(fileloc="dag.py", 
serialized_dags=[])
+        proc.parse_source = manager._bundle_parse_sources["testing"]
+        create.return_value = proc
+        if publication_fails:
+            client.publish_parse_result.side_effect = 
RuntimeError("publication failed")
+        try:
+            manager._start_new_processes()
+            create.assert_called_once_with(manager, file, 
source=proc.parse_source)
+            manager.handle_parsing_result(file, proc, session=session)
+            client.publish_parse_result.assert_not_called()
+            manager._publish_pending_results()
+            body = client.publish_parse_result.call_args.args[0]
+            assert body.attempt_id == proc.id
+            assert body.dispatch_sequence == proc.dispatch_sequence == 1
+            assert body.bundle_version == "v1"
+            assert body.relative_fileloc == "dag.py"
+            assert body.serialized_dags == []
+            assert manager._file_stats[file].run_count == 1
+            persist.assert_not_called()
+        finally:
+            read_end.close()
+            proc.close()
+
+    def test_api_publication_reports_a_result_the_api_would_refuse(self, 
session):
+        client = mock.create_autospec(DagProcessorAPIClient, instance=True)
+        manager = DagFileProcessorManager(max_runs=1, api_client=client)
+        file = _get_file_infos(["dag.py"])[0]
+        dag = SdkDAG("published", schedule=None)
+        dag.fileloc = "/worker/dag.py"
+        dag.relative_fileloc = "dag.py"
+        # Mutating the set skips the SDK's constructor check, so only the API 
model rejects the tag.
+        dag.tags.add("x" * 101)
+        revision = uuid7()
+        proc, read_end = self.mock_processor()
+        proc.had_callbacks = False
+        proc.dispatch_sequence = 1
+        proc.parse_source = DagParseSource(bundle_version="v1", 
bundle_revision=revision)
+        proc.parsing_result = DagFileParsingResult(
+            fileloc=dag.fileloc, 
serialized_dags=[LazyDeserializedDAG.from_dag(dag)]
+        )
+        try:
+            manager.handle_parsing_result(file, proc, session=session)
+            manager._publish_pending_results()
+        finally:
+            read_end.close()
+            proc.close()
+
+        report = client.publish_parse_result.call_args.args[0]
+        assert report.serialized_dags == []
+        assert (report.bundle_version, report.bundle_revision) == ("v1", 
revision)
+        assert report.import_errors == {
+            "dag.py": "The parsing result was not published: it is not a valid 
Dag publication "
+            "(Value error, Dag tags must be strings of at most 100 
characters)."
+        }
+        assert manager._file_stats[file].import_errors == 1
+        assert not manager._pending_publications
+
+    @pytest.mark.parametrize("source_available", [True, False])
+    def 
test_api_publication_carries_diagnostics_and_explicit_source_availability(self, 
source_available):
+        client = mock.create_autospec(DagProcessorAPIClient, instance=True)
+        manager = DagFileProcessorManager(max_runs=1, api_client=client)
+        file = _get_file_infos(["dag.py"])[0]
+        dag = SdkDAG("published", schedule=None)
+        dag.fileloc = "/worker/dag.py"
+        dag.relative_fileloc = "dag.py"
+        proc, read_end = self.mock_processor()
+        proc.dispatch_sequence = 1
+        warning = {"dag_id": dag.dag_id, "warning_type": "python:deprecated", 
"message": "warning"}
+        unserialized_warning = {"dag_id": "failed", "warning_type": 
"python:deprecated", "message": "warning"}
+        proc.parsing_result = DagFileParsingResult(
+            fileloc=dag.fileloc,
+            serialized_dags=[LazyDeserializedDAG.from_dag(dag)],
+            warnings=[warning, unserialized_warning],
+            parsed_definitions=["dag.py"],
+            import_errors={"dag.py": "import error"},
+            dag_source_codes={dag.dag_id: 
DagSourceCode(source_code="captured", language="python")}
+            if source_available
+            else {},
+        )
+        try:
+            body = manager._build_parse_result(file, proc, 0.5)
+            assert [published.model_dump() for published in body.warnings] == 
[warning]
+            assert body.import_errors == {"dag.py": "import error"}
+            assert body.parsed_definitions == ["dag.py"]
+            assert body.source_codes[dag.dag_id].source_code == ("captured" if 
source_available else None)
+        finally:
+            read_end.close()
+            proc.close()
+
+    @pytest.mark.usefixtures("testing_dag_bundle")
+    @conf_vars({("workers", "execution_api_retries"): "1"})
+    @pytest.mark.parametrize("failure", [413, 422, 403, 500, "timeout"])
+    def test_rejected_publication_preserves_stale_detection(self, session, 
time_machine, failure):
+        time_machine.move_to("2026-10-05T12:00:00Z", tick=False)
+        client = mock.create_autospec(DagProcessorAPIClient, instance=True)
+        request = httpx2.Request("POST", "http://api/jobs/1/parse-results";)
+        client.publish_parse_result.side_effect = (
+            httpx2.ReadTimeout("Lost response", request=request)
+            if failure == "timeout"
+            else httpx2.HTTPStatusError("Rejected", request=request, 
response=httpx2.Response(failure))
+        )
+        manager = DagFileProcessorManager(max_runs=1, api_client=client)
+        file = _get_file_infos(["dag.py"])[0]
+        previous = timezone.utcnow() - 
timedelta(seconds=manager.stale_dag_threshold + 1)
+        manager._file_stats[file] = DagFileStat(num_dags=1, 
last_finish_time=previous)
+        dag = SdkDAG("published", schedule=None)
+        dag.fileloc = "/worker/dag.py"
+        dag.relative_fileloc = "dag.py"
+        session.add(
+            DagModel(
+                dag_id=dag.dag_id,
+                bundle_name="testing",
+                relative_fileloc="dag.py",
+                fileloc=dag.fileloc,
+                last_parsed_time=previous,
+                is_stale=False,
+            )
+        )
+        session.commit()
+        proc, read_end = self.mock_processor()
+        proc.dispatch_sequence = 1
+        proc.parsing_result = DagFileParsingResult(
+            fileloc=dag.fileloc, 
serialized_dags=[LazyDeserializedDAG.from_dag(dag)]
+        )
+        try:
+            manager.handle_parsing_result(file, proc)
+            manager._publish_pending_results()
+            manager._scan_stale_dags()

Review Comment:
   `_scan_stale_dags()` returns immediately in API mode, so the `is_stale` 
assertion below passes whatever the publication does. Either drop the call and 
that assertion, or assert something the API path controls.



##########
airflow-core/src/airflow/dag_processing/manager.py:
##########
@@ -103,15 +114,36 @@
     from sqlalchemy.sql import Select
 
     from airflow.callbacks.callback_requests import CallbackRequest
+    from airflow.dag_processing.api_client import DagProcessorAPIClient
     from airflow.dag_processing.bundles.base import BaseDagBundle
     from airflow.sdk.api.client import Client
 
+# Each send has its own short timeout; this bounds how long one loop spends on 
queued sends

Review Comment:
   `_get_write_timeout` only caps connect and pool at 1s; read and write keep 
the client's full timeout. So one slow publish can hold the loop well past this 
1s budget. Maybe say the budget limits when a new send starts, not how long a 
started one runs.



##########
airflow-core/tests/unit/dag_processing/test_manager.py:
##########
@@ -6069,3 +6407,859 @@ def 
test_normalized_file_path_for_stats_does_not_warn(caplog):
 
     assert result == "dags_test_test_dag.py"
     assert caplog.entries == []
+
+
+API_CLIENT_BUNDLES = [
+    {
+        "name": name,
+        "classpath": "airflow.dag_processing.bundles.local.LocalDagBundle",
+        "kwargs": {"path": "/"},
+    }
+    for name in ("bundle_a", "bundle_b")
+]
+
+
+def _read_bundle_connection() -> None:
+    Connection.get("bundle_conn")
+
+
+class TestDagFileProcessorManagerWithAPIClient:
+    """With an API client, bundle code resolves its connections through it, 
for the bundle it belongs to."""
+
+    @pytest.fixture
+    def api_requests(self) -> list[httpx2.Request]:
+        return []
+
+    @pytest.fixture
+    def api_client(self, tmp_path, api_requests):
+        token_file = tmp_path / "processor.jwt"
+        token_file.write_text("session-token")
+        job_token = jwt.encode({"iat": 0, "exp": 300, "scope": 
"dag_processor", "job_id": 1}, "secret")

Review Comment:
   test_api_client.py already has `make_job_token` and 
`make_registration_response`, and test_processor.py builds the same job token 
and `/jobs` handler again. Moving those to a `conftest.py` in this directory 
would let all three share them.



##########
airflow-core/src/airflow/dag_processing/manager.py:
##########
@@ -370,15 +463,18 @@ def run(self):
         By processing them in separate processes, we can get parallelism and 
isolation
         from potentially harmful user code.
         """
-        self.before_run()
         try:
+            self.before_run()
             return self._run_parsing_loop()
         finally:
             self.after_run()
 
     def before_run(self) -> None:
         """Set up state required before the parsing loop starts. Default 
implementation; override to customize."""
-        self.prepare_server_process_context()
+        if self.api_client is None:
+            self.prepare_server_process_context()
+        else:
+            self.prepare_api_secrets_context(self.api_client)

Review Comment:
   The base layer documented override points for an API-backed manager: 
`claim_priority_files` and `fetch_callbacks` say to override them to source 
work from an API, and `prepare_server_process_context` says to make it a no-op 
in API-backed subclasses. This layer puts the API path inside those defaults 
behind `if self.api_client` instead, 21 branches here and 28 at the top of the 
stack. Was a subclass that implements those hooks considered? It would keep the 
DB path free of API checks. If the flag stays, those docstrings need updating, 
since the defaults no longer read only from the metadata DB.



##########
airflow-core/src/airflow/dag_processing/manager.py:
##########
@@ -418,6 +522,28 @@ def prepare_server_process_context(self) -> None:
         # Related: https://github.com/apache/airflow/pull/57459
         os.environ["_AIRFLOW_PROCESS_CONTEXT"] = "server"
 
+    def prepare_api_secrets_context(self, api_client: DagProcessorAPIClient) 
-> None:
+        """
+        Resolve this process's own connection and variable lookups through 
``api_client``.
+
+        Bundle code runs in this process, for example a Git bundle reading its 
connection. Its secret
+        lookups use the API. Parse processes replace

Review Comment:
   Lang-SDK parse processes don't: `_start_runtime_entrypoint` in 
lang_sdk_processor.py never resets `SUPERVISOR_COMMS`, and on Linux it's a bare 
fork. So the `get_dagbag_import_timeout` policy and the coordinator lookup run 
with this process's `DagProcessorSecretsComms` and a forked copy of its httpx 
client. Should that entrypoint drop it too, or should this say only Python 
parse processes replace it?



-- 
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