This is an automated email from the ASF dual-hosted git repository.

jscheffl pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 9a917ecaf3b Relax version checks for remote Edge Workers (#66292)
9a917ecaf3b is described below

commit 9a917ecaf3b4b39ea089a513aa856ac01fa43a47
Author: Jens Scheffler <[email protected]>
AuthorDate: Mon May 4 18:49:47 2026 +0200

    Relax version checks for remote Edge Workers (#66292)
    
    * Relax version checks for remote Edge Workers
    
    * Add pytests
    
    * Apply suggestion from @jschefflReview feedback by Dheeraj
---
 providers/edge3/docs/architecture.rst              |  6 ---
 providers/edge3/provider.yaml                      | 40 ++++++++++++++
 .../src/airflow/providers/edge3/cli/worker.py      | 23 +++++++-
 .../airflow/providers/edge3/get_provider_info.py   | 14 +++++
 .../providers/edge3/worker_api/datamodels.py       | 16 ++++++
 .../providers/edge3/worker_api/routes/worker.py    | 61 +++++++++++++++-------
 .../edge3/worker_api/v2-edge-generated.yaml        | 16 ++++++
 .../edge3/tests/unit/edge3/cli/test_worker.py      | 16 +++++-
 .../unit/edge3/worker_api/routes/test_worker.py    | 24 +++++++++
 9 files changed, 187 insertions(+), 29 deletions(-)

diff --git a/providers/edge3/docs/architecture.rst 
b/providers/edge3/docs/architecture.rst
index e9e586183cf..688ee372844 100644
--- a/providers/edge3/docs/architecture.rst
+++ b/providers/edge3/docs/architecture.rst
@@ -156,17 +156,11 @@ The following features are known missing and will be 
implemented in increments:
 
   - Overview about queues / jobs per queue
   - Allow starting Edge Worker REST API separate to api-server
-  - Add some hints how to setup an additional worker
 
 - Edge Worker CLI
 
   - Use WebSockets instead of HTTP calls for communication
   - Send logs also to TaskFileHandler if external logging services are used
-  - Integration into telemetry to send metrics from remote site
-  - Publish system metrics with heartbeats (CPU, Disk space, RAM, Load)
-  - Be more liberal e.g. on patch version. Currently requires exact version 
match
-    (In current state if versions do not match, the worker will gracefully shut
-    down when jobs are completed, no new jobs will be started)
 
 - Tests
 
diff --git a/providers/edge3/provider.yaml b/providers/edge3/provider.yaml
index c160b23b278..4dab4e1c9c8 100644
--- a/providers/edge3/provider.yaml
+++ b/providers/edge3/provider.yaml
@@ -192,6 +192,46 @@ config:
         type: string
         default: ~
         example: ~
+      minimum_acceptable_core_version_for_workers:
+        description: |
+          The minimum acceptable version of the central Airflow site and edge 
worker to work with.
+
+          This is used to make sure that the central Airflow site and edge 
workers are compatible with each
+          other. When an edge worker connects to the central site, it reports 
its installed airflow
+          core version. If the version is lower than the minimum acceptable 
version, the central site will
+          notify the worker during heartbeat and raise an alert. The worker 
will gracefully terminate and
+          drain jobs and shut down.
+
+          If you know your workers are still compatible with older versions of 
the central site, you can set
+          this to a lower version to avoid having to update all workers at the 
same time as the central site.
+          Default is to force matching versions, so relaxing is optional to be 
configured.
+
+          If the airflow core version between edge worker and central site is 
not matchning, the worker will
+          report warning status.
+        version_added: 3.6.0
+        type: string
+        default: ~
+        example: "3.2.1"
+      minimum_acceptable_edge_version_for_workers:
+        description: |
+          The minimum acceptable version of the edge worker to work with the 
central site.
+
+          This is used to make sure that the central Airflow site and edge 
workers are compatible with each
+          other. When an edge worker connects to the central site, it reports 
its edge version. If the
+          version is lower than the minimum acceptable version, the central 
site will notify the worker
+          during heartbeat and raise an alert. The worker will gracefully 
terminate and drain jobs and shut
+          down.
+
+          If you know your edge workers are still compatible with older 
versions of the central site, you
+          can set this to a lower version to avoid having to update all 
workers at the same time as the
+          central site. Default is to force matching versions, so relaxing is 
optional to be configured.
+
+          If the edge version between edge worker and central site is not 
matchning, the worker will report
+          warning status.
+        version_added: 3.6.0
+        type: string
+        default: ~
+        example: "3.5.0"
       extended_system_info_function:
         description: |
           The function to call to get extended system information for the 
worker.
diff --git a/providers/edge3/src/airflow/providers/edge3/cli/worker.py 
b/providers/edge3/src/airflow/providers/edge3/cli/worker.py
index 2fa261b74f9..510b4568820 100644
--- a/providers/edge3/src/airflow/providers/edge3/cli/worker.py
+++ b/providers/edge3/src/airflow/providers/edge3/cli/worker.py
@@ -114,6 +114,8 @@ class EdgeWorker:
     """Flag if job processing should be completed and no new jobs fetched for 
maintenance mode. """
     maintenance_comments: str | None = None
     """Comments for maintenance mode."""
+    versions_match: bool = True
+    """Whether the worker and the server have matching versions of Airflow and 
the Edge Provider."""
     background_tasks: set[Task] = set()
 
     def __init__(
@@ -327,7 +329,22 @@ class EdgeWorker:
     async def _get_sysinfo(self) -> dict[str, str | int | float | datetime]:
         """Produce the sysinfo from worker to post to central site."""
         sysinfo: dict[str, str | int | float | datetime] = {
-            "status": logging.INFO,
+            **(
+                {
+                    "status": logging.INFO,
+                }
+                if self.versions_match
+                else {
+                    "status": logging.WARNING,
+                    "status_text": "Healthy but version mismatch",
+                    "version_mismatch_description": "The version between the 
Edge Worker and the "
+                    "Airflow Core is not matching for either the edge or 
airflow package version. "
+                    "Please check if the Edge Provider version is compatible 
with your Airflow "
+                    "version. The worker will still operate but you might miss 
some features or "
+                    "have issues. Please consider upgrading the Edge Provider 
to a compatible "
+                    "version.",
+                }
+            ),
             "airflow_version": airflow_version,
             "edge_provider_version": edge_provider_version,
             "python_version": sys.version,
@@ -431,13 +448,14 @@ class EdgeWorker:
     async def start(self):
         """Start the execution in a loop until terminated."""
         try:
-            await worker_register(
+            register_result = await worker_register(
                 self.hostname,
                 EdgeWorkerState.STARTING,
                 self.queues,
                 await self._get_sysinfo(),
                 self.team_name,
             )
+            self.versions_match = register_result.versions_match
         except EdgeWorkerVersionException as e:
             logger.info("Version mismatch of Edge worker and Core. Shutting 
down worker.")
             raise SystemExit(str(e))
@@ -590,6 +608,7 @@ class EdgeWorker:
                 new_maintenance_comments,
                 team_name=self.team_name,
             )
+            self.versions_match = worker_info.versions_match
             self.queues = worker_info.queues
             if worker_info.concurrency is not None and worker_info.concurrency 
!= self.concurrency:
                 logger.info(
diff --git a/providers/edge3/src/airflow/providers/edge3/get_provider_info.py 
b/providers/edge3/src/airflow/providers/edge3/get_provider_info.py
index 7e0085982b2..53ce9805ded 100644
--- a/providers/edge3/src/airflow/providers/edge3/get_provider_info.py
+++ b/providers/edge3/src/airflow/providers/edge3/get_provider_info.py
@@ -123,6 +123,20 @@ def get_provider_info():
                         "default": None,
                         "example": None,
                     },
+                    "minimum_acceptable_core_version_for_workers": {
+                        "description": "The minimum acceptable version of the 
central Airflow site and edge worker to work with.\n\nThis is used to make sure 
that the central Airflow site and edge workers are compatible with each\nother. 
When an edge worker connects to the central site, it reports its installed 
airflow\ncore version. If the version is lower than the minimum acceptable 
version, the central site will\nnotify the worker during heartbeat and raise an 
alert. The worker will g [...]
+                        "version_added": "3.6.0",
+                        "type": "string",
+                        "default": None,
+                        "example": "3.2.1",
+                    },
+                    "minimum_acceptable_edge_version_for_workers": {
+                        "description": "The minimum acceptable version of the 
edge worker to work with the central site.\n\nThis is used to make sure that 
the central Airflow site and edge workers are compatible with each\nother. When 
an edge worker connects to the central site, it reports its edge version. If 
the\nversion is lower than the minimum acceptable version, the central site 
will notify the worker\nduring heartbeat and raise an alert. The worker will 
gracefully terminate and dr [...]
+                        "version_added": "3.6.0",
+                        "type": "string",
+                        "default": None,
+                        "example": "3.5.0",
+                    },
                     "extended_system_info_function": {
                         "description": "The function to call to get extended 
system information for the worker.\n\nThe function must be async and return a 
``dict[str, str | int | float | datetime]``.\nThe information will be sent to 
the central site with each heartbeat and can be used for monitoring\nand 
debugging purposes. All int and float values will also be published to metric 
collection systems\nlike statsd or otel.\n\nFunction must be provided as a 
string with the full path to the  [...]
                         "version_added": "3.5.0",
diff --git 
a/providers/edge3/src/airflow/providers/edge3/worker_api/datamodels.py 
b/providers/edge3/src/airflow/providers/edge3/worker_api/datamodels.py
index aefc1af6119..fb947ee5003 100644
--- a/providers/edge3/src/airflow/providers/edge3/worker_api/datamodels.py
+++ b/providers/edge3/src/airflow/providers/edge3/worker_api/datamodels.py
@@ -192,6 +192,14 @@ class WorkerRegistrationReturn(BaseModel):
     """The return class for the worker registration."""
 
     last_update: Annotated[datetime, Field(description="Time of the last 
update of the worker.")]
+    versions_match: Annotated[
+        bool,
+        Field(
+            description="Whether the worker and the server have matching 
versions of Airflow and the Edge Provider. "
+            "If False, the worker version is not matching and might need to be 
upgraded. But version is still "
+            "compatible enough to work. If True, worker and server versions 
match.",
+        ),
+    ] = False  # If not explicitly given assume it is not compatible
 
 
 class WorkerSetStateReturn(BaseModel):
@@ -215,3 +223,11 @@ class WorkerSetStateReturn(BaseModel):
             "None means no remote override; the worker uses its startup 
value.",
         ),
     ] = None
+    versions_match: Annotated[
+        bool,
+        Field(
+            description="Whether the worker and the server have matching 
versions of Airflow and the Edge Provider. "
+            "If False, the worker version is not matching and might need to be 
upgraded. But version is still "
+            "compatible enough to work. If True, worker and server versions 
match.",
+        ),
+    ] = False  # If not explicitly given assume it is not compatible
diff --git 
a/providers/edge3/src/airflow/providers/edge3/worker_api/routes/worker.py 
b/providers/edge3/src/airflow/providers/edge3/worker_api/routes/worker.py
index 4132be87c44..fca49c987f5 100644
--- a/providers/edge3/src/airflow/providers/edge3/worker_api/routes/worker.py
+++ b/providers/edge3/src/airflow/providers/edge3/worker_api/routes/worker.py
@@ -21,12 +21,15 @@ from datetime import datetime
 from typing import Annotated
 
 from fastapi import Body, Depends, HTTPException, Path, status
+from packaging.version import Version
 from sqlalchemy import select
 
+from airflow import __version__ as airflow_version
 from airflow.api_fastapi.common.db.common import SessionDep  # noqa: TC001
 from airflow.api_fastapi.common.router import AirflowRouter
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
-from airflow.providers.common.compat.sdk import Stats, timezone
+from airflow.providers.common.compat.sdk import Stats, conf, timezone
+from airflow.providers.edge3 import __version__ as edge_provider_version
 from airflow.providers.edge3.models.edge_worker import EdgeWorkerModel, 
EdgeWorkerState, set_metrics
 from airflow.providers.edge3.worker_api.auth import 
jwt_token_authorization_rest
 from airflow.providers.edge3.worker_api.datamodels import (
@@ -49,38 +52,55 @@ worker_router = AirflowRouter(
 )
 
 
-def _assert_version(sysinfo: dict[str, str | int | float | datetime]) -> None:
-    """Check if the Edge Worker version matches the central API site."""
-    from airflow import __version__ as airflow_version
-    from airflow.providers.edge3 import __version__ as edge_provider_version
+def _version(version_str: str) -> tuple[int, int, int]:
+    """Convert a version string into a tuple of integers for comparison."""
+    version = Version(version_str)
+    return version.major, version.minor, version.micro
+
 
-    # Note: In future, more stable versions we might be more liberate, for the
-    #       moment we require exact version match for Edge Worker and core 
version
+def _assert_version(sysinfo: dict[str, str | int | float | datetime]) -> bool:
+    """Check if the Edge Worker version matches the central API site."""
+    versions_match = True
     if "airflow_version" in sysinfo:
-        airflow_on_worker = sysinfo["airflow_version"]
+        airflow_on_worker: str = sysinfo["airflow_version"]  # type: ignore
         if airflow_on_worker != airflow_version:
-            raise HTTPException(
-                status.HTTP_400_BAD_REQUEST,
-                f"Edge Worker runs on Airflow {airflow_on_worker} "
-                f"and the core runs on {airflow_version}. Rejecting access due 
to difference.",
+            versions_match = False
+            minimum_acceptable_core_version_for_workers = conf.get(
+                "edge", "minimum_acceptable_core_version_for_workers", 
fallback=None
             )
+            if not minimum_acceptable_core_version_for_workers or 
_version(airflow_on_worker) < _version(
+                minimum_acceptable_core_version_for_workers
+            ):
+                raise HTTPException(
+                    status.HTTP_400_BAD_REQUEST,
+                    f"Edge Worker runs on Airflow {airflow_on_worker} which is 
below the minimum acceptable version "
+                    f"{minimum_acceptable_core_version_for_workers}. Rejecting 
access due to incompatible version.",
+                )
     else:
         raise HTTPException(
             status.HTTP_400_BAD_REQUEST, "Edge Worker does not specify the 
version it is running on."
         )
 
     if "edge_provider_version" in sysinfo:
-        provider_on_worker = sysinfo["edge_provider_version"]
+        provider_on_worker: str = sysinfo["edge_provider_version"]  # type: 
ignore
         if provider_on_worker != edge_provider_version:
-            raise HTTPException(
-                status.HTTP_400_BAD_REQUEST,
-                f"Edge Worker runs on Edge Provider {provider_on_worker} "
-                f"and the core runs on {edge_provider_version}. Rejecting 
access due to difference.",
+            versions_match = False
+            minimum_acceptable_edge_version_for_workers = conf.get(
+                "edge", "minimum_acceptable_edge_version_for_workers", 
fallback=None
             )
+            if not minimum_acceptable_edge_version_for_workers or 
_version(provider_on_worker) < _version(
+                minimum_acceptable_edge_version_for_workers
+            ):
+                raise HTTPException(
+                    status.HTTP_400_BAD_REQUEST,
+                    f"Edge Worker runs on Edge Provider {provider_on_worker} "
+                    f"and the core runs on {edge_provider_version}. Rejecting 
access due to difference.",
+                )
     else:
         raise HTTPException(
             status.HTTP_400_BAD_REQUEST, "Edge Worker does not specify the 
provider version it is running on."
         )
+    return versions_match
 
 
 _worker_name_doc = Path(title="Worker Name", description="Hostname or instance 
name of the worker")
@@ -164,7 +184,7 @@ def register(
     session: SessionDep,
 ) -> WorkerRegistrationReturn:
     """Register a new worker to the backend."""
-    _assert_version(body.sysinfo)
+    versions_match = _assert_version(body.sysinfo)
     query = select(EdgeWorkerModel).where(EdgeWorkerModel.worker_name == 
worker_name)
     worker: EdgeWorkerModel | None = session.scalar(query)
     if not worker:
@@ -193,7 +213,7 @@ def register(
     worker.last_update = timezone.utcnow()
     worker.team_name = body.team_name
     session.add(worker)
-    return WorkerRegistrationReturn(last_update=worker.last_update)
+    return WorkerRegistrationReturn(last_update=worker.last_update, 
versions_match=versions_match)
 
 
 @worker_router.patch("/{worker_name}", 
dependencies=[Depends(jwt_token_authorization_rest)])
@@ -227,12 +247,13 @@ def set_state(
         queues=worker.queues,
         sysinfo=body.sysinfo,
     )
-    _assert_version(body.sysinfo)  # Exception only after worker state is in 
the DB
+    versions_match = _assert_version(body.sysinfo)  # Exception only after 
worker state is in the DB
     return WorkerSetStateReturn(
         state=worker.state,
         queues=worker.queues,
         maintenance_comments=worker.maintenance_comment,
         concurrency=worker.concurrency,
+        versions_match=versions_match,
     )
 
 
diff --git 
a/providers/edge3/src/airflow/providers/edge3/worker_api/v2-edge-generated.yaml 
b/providers/edge3/src/airflow/providers/edge3/worker_api/v2-edge-generated.yaml
index b9388b6b6c9..01c8149d1da 100644
--- 
a/providers/edge3/src/airflow/providers/edge3/worker_api/v2-edge-generated.yaml
+++ 
b/providers/edge3/src/airflow/providers/edge3/worker_api/v2-edge-generated.yaml
@@ -1453,6 +1453,14 @@ components:
           format: date-time
           title: Last Update
           description: Time of the last update of the worker.
+        versions_match:
+          type: boolean
+          title: Versions Match
+          description: Whether the worker and the server have matching 
versions of
+            Airflow and the Edge Provider. If False, the worker version is not 
matching
+            and might need to be upgraded. But version is still compatible 
enough
+            to work. If True, worker and server versions match.
+          default: false
       type: object
       required:
       - last_update
@@ -1485,6 +1493,14 @@ components:
           title: Concurrency
           description: Desired concurrency for the worker set by an 
administrator.
             None means no remote override; the worker uses its startup value.
+        versions_match:
+          type: boolean
+          title: Versions Match
+          description: Whether the worker and the server have matching 
versions of
+            Airflow and the Edge Provider. If False, the worker version is not 
matching
+            and might need to be upgraded. But version is still compatible 
enough
+            to work. If True, worker and server versions match.
+          default: false
       type: object
       required:
       - state
diff --git a/providers/edge3/tests/unit/edge3/cli/test_worker.py 
b/providers/edge3/tests/unit/edge3/cli/test_worker.py
index 933793a4f06..ced12428502 100644
--- a/providers/edge3/tests/unit/edge3/cli/test_worker.py
+++ b/providers/edge3/tests/unit/edge3/cli/test_worker.py
@@ -20,6 +20,7 @@ import asyncio
 import contextlib
 import importlib
 import json
+import logging
 import multiprocessing
 import signal
 from datetime import datetime
@@ -683,10 +684,23 @@ class TestEdgeWorker:
         assert "concurrency" in sysinfo
         assert "worker_start_time" in sysinfo
         assert sysinfo["worker_start_time"] == 
worker_with_job.worker_start_time
-        assert "status" in sysinfo
+        assert sysinfo["status"] == logging.INFO
         assert "status_text" not in sysinfo  # is only defined if extended 
sysinfo provides this field
         assert sysinfo["concurrency"] == concurrency
 
+    @pytest.mark.asyncio
+    async def test_get_sysinfo_version_mismatch(self, worker_with_job: 
EdgeWorker):
+        worker_with_job.versions_match = False  # Simulate version mismatch to 
verify sysinfo
+        sysinfo = await worker_with_job._get_sysinfo()
+        assert "airflow_version" in sysinfo
+        assert "edge_provider_version" in sysinfo
+        assert "python_version" in sysinfo
+        assert "concurrency" in sysinfo
+        assert "worker_start_time" in sysinfo
+        assert sysinfo["worker_start_time"] == 
worker_with_job.worker_start_time
+        assert sysinfo["status"] == logging.WARNING
+        assert "status_text" in sysinfo
+
     @pytest.mark.asyncio
     async def test_get_sysinfo_extended(self, worker_with_job_and_sysinfo: 
EdgeWorker):
         concurrency = 42
diff --git a/providers/edge3/tests/unit/edge3/worker_api/routes/test_worker.py 
b/providers/edge3/tests/unit/edge3/worker_api/routes/test_worker.py
index fef1c9d5025..099ba7e8827 100644
--- a/providers/edge3/tests/unit/edge3/worker_api/routes/test_worker.py
+++ b/providers/edge3/tests/unit/edge3/worker_api/routes/test_worker.py
@@ -37,11 +37,14 @@ from airflow.providers.edge3.models.edge_worker import (
 from airflow.providers.edge3.worker_api.datamodels import 
WorkerQueueUpdateBody, WorkerStateBody
 from airflow.providers.edge3.worker_api.routes.worker import (
     _assert_version,
+    _version,
     register,
     set_state,
     update_queues,
 )
 
+from tests_common.test_utils.config import conf_vars
+
 if TYPE_CHECKING:
     from sqlalchemy.orm import Session
 
@@ -68,6 +71,11 @@ class TestWorkerApiRoutes:
     def setup_test_cases(self, session: Session):
         session.execute(delete(EdgeWorkerModel))
 
+    def test_version(self):
+        assert _version("1.2.3") == (1, 2, 3)
+        assert _version("1.2.3rc1") == (1, 2, 3)
+        assert _version("1.2.3.dev0") == (1, 2, 3)
+
     def test_assert_version(self):
         from airflow import __version__ as airflow_version
         from airflow.providers.edge3 import __version__ as 
edge_provider_version
@@ -87,6 +95,22 @@ class TestWorkerApiRoutes:
         with pytest.raises(HTTPException):
             _assert_version({"airflow_version": airflow_version, 
"edge_provider_version": "2023.10.07"})
 
+        with conf_vars({("edge", 
"minimum_acceptable_edge_version_for_workers"): "3.2.0"}):
+            with pytest.raises(HTTPException):
+                _assert_version({"airflow_version": airflow_version, 
"edge_provider_version": "3.1.0"})
+
+            _assert_version({"airflow_version": airflow_version, 
"edge_provider_version": "3.2.0"})
+            _assert_version({"airflow_version": airflow_version, 
"edge_provider_version": "3.2.1rc1"})
+            _assert_version({"airflow_version": airflow_version, 
"edge_provider_version": "4.1.1"})
+
+        with conf_vars({("edge", 
"minimum_acceptable_core_version_for_workers"): "3.1.0"}):
+            with pytest.raises(HTTPException):
+                _assert_version({"airflow_version": "3.0.0", 
"edge_provider_version": edge_provider_version})
+
+            _assert_version({"airflow_version": "3.1.0", 
"edge_provider_version": edge_provider_version})
+            _assert_version({"airflow_version": "3.2.0rc3", 
"edge_provider_version": edge_provider_version})
+            _assert_version({"airflow_version": "4.1.0", 
"edge_provider_version": edge_provider_version})
+
         _assert_version({"airflow_version": airflow_version, 
"edge_provider_version": edge_provider_version})
 
     @pytest.mark.parametrize(

Reply via email to