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

dheerajturaga 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 2f3e60cb949 Support priority_weight in EdgeExecutor (#72057)
2f3e60cb949 is described below

commit 2f3e60cb94901e9f9bc47e3b0aeb7f8290e4e983
Author: PoAn Yang <[email protected]>
AuthorDate: Wed Oct 14 03:18:50 2026 +0800

    Support priority_weight in EdgeExecutor (#72057)
    
    * Support priority_weight in EdgeExecutor
    
    Signed-off-by: PoAn Yang <[email protected]>
    
    * Move Edge priority_weight migration to edge3 5.1.0
    
    edge3 5.0.0 was released with f2a4b6c8d0e1 as its migration head, so this
    migration ships in the next release. Recording it under 5.0.0 made
    `airflow db-manager` resolve `--to-version 5.0.0` and `--from-version 5.0.0`
    to the wrong revision.
    
    ---------
    
    Signed-off-by: PoAn Yang <[email protected]>
    Co-authored-by: Dheeraj Turaga <[email protected]>
---
 providers/edge3/docs/changelog.rst                 |   9 +
 providers/edge3/docs/edge_executor.rst             |   7 +
 providers/edge3/docs/migrations-ref.rst            |   4 +-
 .../providers/edge3/executors/edge_executor.py     |   2 +
 .../0007_5_1_0_add_priority_weight_to_edge_job.py  |  55 +++++
 .../edge3/src/airflow/providers/edge3/models/db.py |   1 +
 .../src/airflow/providers/edge3/models/edge_job.py |   5 +-
 .../src/airflow/providers/edge3/models/types.py    |  20 +-
 .../providers/edge3/worker_api/routes/jobs.py      |  34 ++--
 .../unit/edge3/executors/test_edge_executor.py     |  19 +-
 providers/edge3/tests/unit/edge3/models/test_db.py |  72 ++++++-
 .../unit/edge3/worker_api/routes/test_jobs.py      | 222 ++++++++++++++++++++-
 12 files changed, 413 insertions(+), 37 deletions(-)

diff --git a/providers/edge3/docs/changelog.rst 
b/providers/edge3/docs/changelog.rst
index 70a1ffb9cd8..4d6c9ab0778 100644
--- a/providers/edge3/docs/changelog.rst
+++ b/providers/edge3/docs/changelog.rst
@@ -27,6 +27,15 @@
 Changelog
 ---------
 
+.. note::
+  Instead of strict queue order, ``EdgeExecutor`` now hands out queued 
callbacks first, then tasks
+  by ``priority_weight``, as in the other executors. A steady stream of 
higher-priority tasks can
+  hold back lower-priority tasks on the same queue. With the default
+  ``[core] default_task_weight_rule`` of ``downstream``, a task's weight 
includes the weights of its
+  downstream tasks. Upstream tasks of large Dags therefore go first even when 
no ``priority_weight``
+  is set. Setting the rule to ``absolute`` keeps tasks without their own 
``priority_weight`` in
+  queue order among themselves. The rule affects every executor.
+
 5.0.0
 .....
 
diff --git a/providers/edge3/docs/edge_executor.rst 
b/providers/edge3/docs/edge_executor.rst
index b29dafbe3af..df6e697e734 100644
--- a/providers/edge3/docs/edge_executor.rst
+++ b/providers/edge3/docs/edge_executor.rst
@@ -54,6 +54,13 @@ could take thousands of tasks without a problem), or from an 
environment
 perspective (you want a worker running from a specific location where required
 infrastructure is available).
 
+Within the queues a worker listens to, jobs are handed out by
+:ref:`apache-airflow:concepts:priority-weight`. Among the queued tasks that 
fit in the worker's
+free :ref:`concurrency slots <edge_executor:concurrency_slots>`, the one with 
the highest
+``priority_weight`` is picked up first. Tasks of equal weight go in the order 
they were queued.
+:ref:`Callbacks <edge_executor:execute_callback>` are picked up before any 
task, in the order they
+were queued.
+
 When using EdgeExecutor in addition to other executors and EdgeExecutor not 
being the default executor
 (that is to say the first one in the list of executors), be reminded to also 
define EdgeExecutor
 as the executor at task or Dag level in addition to the queues you are 
targeting.
diff --git a/providers/edge3/docs/migrations-ref.rst 
b/providers/edge3/docs/migrations-ref.rst
index 45600d053a2..e4ade8ae266 100644
--- a/providers/edge3/docs/migrations-ref.rst
+++ b/providers/edge3/docs/migrations-ref.rst
@@ -34,7 +34,9 @@ Here's the list of all the Database Migrations that are 
executed via when you ru
 
+-------------------------+------------------+-----------------+----------------------------------------------------------+
 | Revision ID             | Revises ID       | Edge3 Version   | Description   
                                           |
 
+=========================+==================+=================+==========================================================+
-| ``f2a4b6c8d0e1`` (head) | ``c6b3c3d093fd`` | ``5.0.0``       | Add task 
instance identity to Edge jobs.                 |
+| ``d5a2f8b41c07`` (head) | ``f2a4b6c8d0e1`` | ``5.1.0``       | Add 
priority_weight column to edge_job table.            |
++-------------------------+------------------+-----------------+----------------------------------------------------------+
+| ``f2a4b6c8d0e1``        | ``c6b3c3d093fd`` | ``5.0.0``       | Add task 
instance identity to Edge jobs.                 |
 
+-------------------------+------------------+-----------------+----------------------------------------------------------+
 | ``c6b3c3d093fd``        | ``a09c3ee8e1d3`` | ``3.5.0``       | Replace 
individual counters with extended JSON based     |
 |                         |                  |                 | sysinfo.      
                                           |
diff --git 
a/providers/edge3/src/airflow/providers/edge3/executors/edge_executor.py 
b/providers/edge3/src/airflow/providers/edge3/executors/edge_executor.py
index 11bcda605dd..2d298a16386 100644
--- a/providers/edge3/src/airflow/providers/edge3/executors/edge_executor.py
+++ b/providers/edge3/src/airflow/providers/edge3/executors/edge_executor.py
@@ -186,6 +186,7 @@ class EdgeExecutor(BaseExecutor):
                 existing_job.state = TaskInstanceState.QUEUED
                 existing_job.queue = task_instance.queue
                 existing_job.concurrency_slots = task_instance.pool_slots
+                existing_job.priority_weight = task_instance.priority_weight
                 existing_job.command = workload.model_dump_json()
                 existing_job.team_name = self.team_name
             else:
@@ -200,6 +201,7 @@ class EdgeExecutor(BaseExecutor):
                         state=TaskInstanceState.QUEUED,
                         queue=task_instance.queue,
                         concurrency_slots=task_instance.pool_slots,
+                        priority_weight=task_instance.priority_weight,
                         command=workload.model_dump_json(),
                         team_name=self.team_name,
                     )
diff --git 
a/providers/edge3/src/airflow/providers/edge3/migrations/versions/0007_5_1_0_add_priority_weight_to_edge_job.py
 
b/providers/edge3/src/airflow/providers/edge3/migrations/versions/0007_5_1_0_add_priority_weight_to_edge_job.py
new file mode 100644
index 00000000000..30aa0a1c579
--- /dev/null
+++ 
b/providers/edge3/src/airflow/providers/edge3/migrations/versions/0007_5_1_0_add_priority_weight_to_edge_job.py
@@ -0,0 +1,55 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Add priority_weight column to edge_job table.
+
+Revision ID: d5a2f8b41c07
+Revises: f2a4b6c8d0e1
+Create Date: 2026-08-25 00:00:00.000000
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "d5a2f8b41c07"
+down_revision = "f2a4b6c8d0e1"
+branch_labels = None
+depends_on = None
+edge3_version = "5.1.0"
+
+
+def upgrade() -> None:
+    with op.batch_alter_table("edge_job", schema=None) as batch_op:
+        batch_op.add_column(
+            sa.Column("priority_weight", sa.Integer(), 
server_default=sa.text("1"), nullable=False)
+        )
+    op.drop_index("rj_order", table_name="edge_job")
+    op.create_index(
+        "rj_order", "edge_job", ["state", sa.text("priority_weight DESC"), 
"queued_dttm", "queue"]
+    )
+
+
+def downgrade() -> None:
+    op.drop_index("rj_order", table_name="edge_job")
+    op.create_index("rj_order", "edge_job", ["state", "queued_dttm", "queue"])
+    with op.batch_alter_table("edge_job", schema=None) as batch_op:
+        batch_op.drop_column("priority_weight")
diff --git a/providers/edge3/src/airflow/providers/edge3/models/db.py 
b/providers/edge3/src/airflow/providers/edge3/models/db.py
index f997afe49b0..315b8397d81 100644
--- a/providers/edge3/src/airflow/providers/edge3/models/db.py
+++ b/providers/edge3/src/airflow/providers/edge3/models/db.py
@@ -48,6 +48,7 @@ _REVISION_HEADS_MAP: dict[str, str] = {
     "3.4.0": "a09c3ee8e1d3",
     "3.5.0": "c6b3c3d093fd",
     "5.0.0": "f2a4b6c8d0e1",
+    "5.1.0": "d5a2f8b41c07",
 }
 
 
diff --git a/providers/edge3/src/airflow/providers/edge3/models/edge_job.py 
b/providers/edge3/src/airflow/providers/edge3/models/edge_job.py
index 45192181003..41c8c2f5c17 100644
--- a/providers/edge3/src/airflow/providers/edge3/models/edge_job.py
+++ b/providers/edge3/src/airflow/providers/edge3/models/edge_job.py
@@ -84,6 +84,7 @@ class EdgeJobModel(Base, LoggingMixin):
     state: Mapped[str] = mapped_column(String(20))
     queue: Mapped[str] = mapped_column(String(256))
     concurrency_slots: Mapped[int] = mapped_column(Integer)
+    priority_weight: Mapped[int] = mapped_column(Integer, nullable=False, 
server_default=text("1"))
     command: Mapped[str] = mapped_column(String(2048))
     queued_dttm: Mapped[datetime | None] = mapped_column(UtcDateTime)
     edge_worker: Mapped[str | None] = mapped_column(String(64))
@@ -101,6 +102,7 @@ class EdgeJobModel(Base, LoggingMixin):
         queue: str,
         concurrency_slots: int,
         command: str,
+        priority_weight: int = 1,
         queued_dttm: datetime | None = None,
         edge_worker: str | None = None,
         last_update: datetime | None = None,
@@ -116,6 +118,7 @@ class EdgeJobModel(Base, LoggingMixin):
         self.queue = queue
         self.concurrency_slots = concurrency_slots
         self.command = command
+        self.priority_weight = priority_weight
         self.queued_dttm = queued_dttm or timezone.utcnow()
         self.edge_worker = edge_worker
         self.last_update = last_update
@@ -123,7 +126,7 @@ class EdgeJobModel(Base, LoggingMixin):
         self.task_instance_id = task_instance_id
         super().__init__()
 
-    __table_args__ = (Index("rj_order", state, queued_dttm, queue),)
+    __table_args__ = (Index("rj_order", state, priority_weight.desc(), 
queued_dttm, queue),)
 
     @property
     def key(self) -> TaskInstanceKey | CallbackKey:
diff --git a/providers/edge3/src/airflow/providers/edge3/models/types.py 
b/providers/edge3/src/airflow/providers/edge3/models/types.py
index dce93f52f82..1eb4e143d2b 100644
--- a/providers/edge3/src/airflow/providers/edge3/models/types.py
+++ b/providers/edge3/src/airflow/providers/edge3/models/types.py
@@ -18,10 +18,14 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING, TypeAlias, TypeGuard
 
+from sqlalchemy import and_, literal
+
 from airflow.executors.workloads import ExecuteTask
 from airflow.providers.edge3.version_compat import AIRFLOW_V_3_3_PLUS
 
 if TYPE_CHECKING:
+    from sqlalchemy.sql.elements import ColumnElement
+
     from airflow.executors import workloads
     from airflow.executors.workloads import ExecuteCallback
 
@@ -49,10 +53,11 @@ EXECUTE_CALLBACK_TAG = "ExecuteCallback"
 # Dag id, so a row is a callback only when all four fields match.
 CALLBACK_JOB_TRY_NUMBER = 0
 CALLBACK_JOB_MAP_INDEX = -1
+CALLBACK_JOB_RUN_ID_PREFIX = f"{EXECUTE_CALLBACK_TAG}-"
 
 
 def build_callback_run_id(callback_id: str) -> str:
-    return f"{EXECUTE_CALLBACK_TAG}-{callback_id}"
+    return f"{CALLBACK_JOB_RUN_ID_PREFIX}{callback_id}"
 
 
 def is_callback_job(dag_id: str, task_id: str, run_id: str, try_number: int, 
map_index: int) -> bool:
@@ -63,3 +68,16 @@ def is_callback_job(dag_id: str, task_id: str, run_id: str, 
try_number: int, map
         and try_number == CALLBACK_JOB_TRY_NUMBER
         and map_index == CALLBACK_JOB_MAP_INDEX
     )
+
+
+def build_callback_job_filter() -> ColumnElement[bool]:
+    """Return :func:`is_callback_job` as a SQL filter on ``edge_job`` rows."""
+    # Imported here because the edge_job module imports this one.
+    from airflow.providers.edge3.models.edge_job import EdgeJobModel
+
+    return and_(
+        EdgeJobModel.dag_id == EXECUTE_CALLBACK_TAG,
+        EdgeJobModel.run_id == literal(CALLBACK_JOB_RUN_ID_PREFIX) + 
EdgeJobModel.task_id,
+        EdgeJobModel.try_number == CALLBACK_JOB_TRY_NUMBER,
+        EdgeJobModel.map_index == CALLBACK_JOB_MAP_INDEX,
+    )
diff --git 
a/providers/edge3/src/airflow/providers/edge3/worker_api/routes/jobs.py 
b/providers/edge3/src/airflow/providers/edge3/worker_api/routes/jobs.py
index 5d1b791b763..ea1073b88f1 100644
--- a/providers/edge3/src/airflow/providers/edge3/worker_api/routes/jobs.py
+++ b/providers/edge3/src/airflow/providers/edge3/worker_api/routes/jobs.py
@@ -31,6 +31,7 @@ from airflow.executors.workloads import ExecuteTask
 from airflow.providers.common.compat.sdk import Stats, timezone
 from airflow.providers.edge3.models.edge_job import EdgeJobModel
 from airflow.providers.edge3.models.edge_worker import EdgeWorkerModel
+from airflow.providers.edge3.models.types import build_callback_job_filter, 
is_callback_job
 from airflow.providers.edge3.version_compat import AIRFLOW_V_3_3_PLUS
 from airflow.providers.edge3.worker_api.auth import 
jwt_token_authorization_rest
 from airflow.providers.edge3.worker_api.datamodels import (
@@ -49,13 +50,13 @@ log = logging.getLogger(__name__)
 jobs_router = AirflowRouter(tags=["Jobs"], prefix="/jobs")
 
 
-def parse_command(command: str, dag_id: str, run_id: str) -> ExecuteTypeBody:
-    if AIRFLOW_V_3_3_PLUS:
+def parse_command(
+    command: str, dag_id: str, task_id: str, run_id: str, try_number: int, 
map_index: int
+) -> ExecuteTypeBody:
+    if AIRFLOW_V_3_3_PLUS and is_callback_job(dag_id, task_id, run_id, 
try_number, map_index):
         from airflow.executors.workloads import ExecuteCallback
-        from airflow.providers.edge3.models.types import EXECUTE_CALLBACK_TAG
 
-        if dag_id == EXECUTE_CALLBACK_TAG and 
run_id.startswith(EXECUTE_CALLBACK_TAG):
-            return ExecuteCallback.model_validate_json(command)  # type: 
ignore[return-value]
+        return ExecuteCallback.model_validate_json(command)  # type: 
ignore[return-value]
 
     return ExecuteTask.model_validate_json(command)
 
@@ -88,13 +89,9 @@ def fetch(
     if not worker:
         raise HTTPException(status.HTTP_404_NOT_FOUND, "Worker not found")
 
-    query = (
-        select(EdgeJobModel)
-        .where(
-            EdgeJobModel.state == TaskInstanceState.QUEUED,
-            EdgeJobModel.concurrency_slots <= body.free_concurrency,
-        )
-        .order_by(EdgeJobModel.queued_dttm)
+    query = select(EdgeJobModel).where(
+        EdgeJobModel.state == TaskInstanceState.QUEUED,
+        EdgeJobModel.concurrency_slots <= body.free_concurrency,
     )
     if body.queues:
         query = query.where(EdgeJobModel.queue.in_(body.queues))
@@ -102,7 +99,14 @@ def fetch(
         query = query.where(EdgeJobModel.team_name == worker.team_name)
     query = query.limit(1)
     query = query.with_for_update(skip_locked=True)
-    job: EdgeJobModel | None = session.scalar(query)
+
+    # Callbacks go first, as in other executors (WORKLOAD_TYPE_PRIORITY). A 
single query would sort
+    # the whole backlog. dag_id leads the primary key, so the callback query 
reads only callbacks.
+    job: EdgeJobModel | None = session.scalar(
+        
query.where(build_callback_job_filter()).order_by(EdgeJobModel.queued_dttm)
+    )
+    if job is None:
+        job = 
session.scalar(query.order_by(EdgeJobModel.priority_weight.desc(), 
EdgeJobModel.queued_dttm))
     if not job:
         return None
     if job.task_instance_id and not body.supports_task_instance_uuid:
@@ -125,7 +129,9 @@ def fetch(
         run_id=job.run_id,
         map_index=job.map_index,
         try_number=job.try_number,
-        command=parse_command(job.command, job.dag_id, job.run_id),
+        command=parse_command(
+            job.command, job.dag_id, job.task_id, job.run_id, job.try_number, 
job.map_index
+        ),
         concurrency_slots=job.concurrency_slots,
         task_instance_id=UUID(job.task_instance_id) if job.task_instance_id 
else None,
     )
diff --git a/providers/edge3/tests/unit/edge3/executors/test_edge_executor.py 
b/providers/edge3/tests/unit/edge3/executors/test_edge_executor.py
index df117c00d73..023b248bd90 100644
--- a/providers/edge3/tests/unit/edge3/executors/test_edge_executor.py
+++ b/providers/edge3/tests/unit/edge3/executors/test_edge_executor.py
@@ -844,7 +844,9 @@ class _WorkloadFactory:
             session.execute(delete(EdgeJobModel))
             session.commit()
 
-    def _make_execute_task(self, task_id: str = "test_task", dag_id: str = 
"test_dag") -> ExecuteTask:
+    def _make_execute_task(
+        self, task_id: str = "test_task", dag_id: str = "test_dag", 
priority_weight: int = 1
+    ) -> ExecuteTask:
         ti = TaskInstanceDTO(
             id=uuid4(),
             dag_version_id=uuid4(),
@@ -855,7 +857,7 @@ class _WorkloadFactory:
             map_index=-1,
             pool_slots=1,
             queue="default",
-            priority_weight=1,
+            priority_weight=priority_weight,
         )
         return ExecuteTask(
             ti=ti,
@@ -1074,6 +1076,19 @@ class TestQueueWorkload(_WorkloadFactory):
             assert len(jobs) == 1
             assert jobs[0].state == TaskInstanceState.QUEUED
 
+    def test_queue_workload_stores_task_priority_weight(self):
+        executor = EdgeExecutor()
+
+        with create_session() as session:
+            
executor.queue_workload(self._make_execute_task(priority_weight=42), 
session=session)
+        with create_session() as session:
+            assert session.scalar(select(EdgeJobModel)).priority_weight == 42
+
+        with create_session() as session:
+            
executor.queue_workload(self._make_execute_task(priority_weight=7), 
session=session)
+        with create_session() as session:
+            assert session.scalar(select(EdgeJobModel)).priority_weight == 7
+
     def test_queue_workload_execute_callback(self):
         executor = EdgeExecutor()
         workload = self._make_execute_callback()
diff --git a/providers/edge3/tests/unit/edge3/models/test_db.py 
b/providers/edge3/tests/unit/edge3/models/test_db.py
index 2405f39fdb4..d44640a7289 100644
--- a/providers/edge3/tests/unit/edge3/models/test_db.py
+++ b/providers/edge3/tests/unit/edge3/models/test_db.py
@@ -22,10 +22,12 @@ from unittest import mock
 
 import pytest
 import sqlalchemy as sa
+from alembic import command
 from alembic.migration import MigrationContext
 from alembic.operations import Operations
 
 from airflow import settings
+from airflow.providers.edge3.models.db import EdgeDBManager
 from airflow.providers.edge3.models.edge_base import edge_metadata
 from airflow.utils.db_manager import RunDBManager
 
@@ -244,9 +246,6 @@ class TestEdgeDBManager:
         """Test that initdb runs incremental migrations when tables exist but 
alembic version table does not."""
         from sqlalchemy import inspect, text
 
-        from airflow import settings
-        from airflow.providers.edge3.models.db import EdgeDBManager
-
         manager = EdgeDBManager(session)
 
         # Simulate pre-alembic state: tables exist but no version table and no 
concurrency column
@@ -281,7 +280,7 @@ class TestEdgeDBManager:
             version = conn.execute(text("SELECT version_num FROM 
alembic_version_edge3")).scalar()
             columns = {col["name"] for col in 
inspect(conn).get_columns("edge_worker")}
 
-        assert version == "f2a4b6c8d0e1"
+        assert version in manager.get_script_object().get_heads()
         assert "concurrency" in columns
         assert "team_name" in columns
 
@@ -291,9 +290,6 @@ class TestEdgeDBManager:
         """Test upgradedb runs incremental migrations when tables exist but 
alembic version table does not."""
         from sqlalchemy import inspect, text
 
-        from airflow import settings
-        from airflow.providers.edge3.models.db import EdgeDBManager
-
         manager = EdgeDBManager(session)
 
         # Simulate pre-alembic state: tables exist but no version table and no 
concurrency column
@@ -334,14 +330,10 @@ class TestEdgeDBManager:
 
     def test_migration_adds_concurrency_column(self, session, 
legacy_edge_job_table):
         """Test that upgrading from 3.0.0 actually adds the concurrency 
column."""
-        from alembic import command
         from alembic.migration import MigrationContext
         from alembic.operations import Operations
         from sqlalchemy import inspect
 
-        from airflow import settings
-        from airflow.providers.edge3.models.db import EdgeDBManager
-
         manager = EdgeDBManager(session)
         config = manager.get_alembic_config()
 
@@ -380,6 +372,64 @@ class TestEdgeDBManager:
         assert "concurrency" in columns, "Migration 0002 should have added the 
concurrency column"
         assert "team_name" in columns, "Migration 0003 should have added the 
team_name column"
 
+    @pytest.fixture
+    def restore_edge_tables(self, session):
+        """
+        Rebuild the edge3 tables at head with the migrations, whatever state 
the test left behind.
+
+        From Airflow 3.2 on, the test database also builds these tables with 
the migrations.
+        ``create_all()`` would give the columns different collations on MySQL.
+        """
+        yield
+        # The test's session is still in a transaction that read the Alembic 
version table. On
+        # PostgreSQL, that read holds a lock, so the DROP on another 
connection would wait forever.
+        session.rollback()
+        manager = EdgeDBManager(session)
+        with settings.engine.begin() as conn:
+            manager.drop_tables(conn)
+        manager.upgradedb(use_migration_files=True)
+
+    def test_priority_weight_migration_upgrade_and_downgrade(self, session, 
restore_edge_tables):
+        manager = EdgeDBManager(session)
+        config = manager.get_alembic_config()
+        # Before Airflow 3.2, the edge3 tables come from the conftest's 
create_all(), which leaves no
+        # Alembic version to downgrade from. Stamping changes nothing when the 
head is already recorded.
+        command.stamp(config, "heads")
+        command.downgrade(config, "f2a4b6c8d0e1")
+        # SQLite reflects indexes from a connection's cached schema. The 
downgrade ran on another pooled
+        # connection, so idle connections can still hold the old schema until 
the pool is disposed.
+        settings.engine.dispose()
+
+        with settings.engine.begin() as conn:
+            inspector = sa.inspect(conn)
+            rj_order = next(idx for idx in inspector.get_indexes("edge_job") 
if idx["name"] == "rj_order")
+            assert "priority_weight" not in {col["name"] for col in 
inspector.get_columns("edge_job")}
+            assert rj_order["column_names"] == ["state", "queued_dttm", 
"queue"]
+            conn.execute(
+                sa.text(
+                    "INSERT INTO edge_job (dag_id, task_id, run_id, map_index, 
try_number, state, queue, "
+                    "concurrency_slots, command) "
+                    "VALUES ('dag', 'task', 'run', -1, 1, 'queued', 'default', 
1, '{}')"
+                )
+            )
+
+        manager.upgradedb()
+        # The upgrade also ran on another connection, so dispose the pool 
again.
+        settings.engine.dispose()
+
+        with settings.engine.connect() as conn:
+            inspector = sa.inspect(conn)
+            rj_order = next(idx for idx in inspector.get_indexes("edge_job") 
if idx["name"] == "rj_order")
+            priority_weight = conn.execute(
+                sa.text("SELECT priority_weight FROM edge_job WHERE dag_id = 
'dag'")
+            ).scalar_one()
+
+        assert rj_order["column_names"] == ["state", "priority_weight", 
"queued_dttm", "queue"]
+        # Of the supported backends, only PostgreSQL reflects the sort order 
of index columns.
+        if settings.engine.dialect.name == "postgresql":
+            assert rj_order["column_sorting"] == {"priority_weight": ("desc",)}
+        assert priority_weight == 1
+
     def test_drop_tables_handles_missing_tables(self, session):
         """Test that drop_tables handles missing tables gracefully."""
         from airflow.providers.edge3.models.db import EdgeDBManager
diff --git a/providers/edge3/tests/unit/edge3/worker_api/routes/test_jobs.py 
b/providers/edge3/tests/unit/edge3/worker_api/routes/test_jobs.py
index 4a53dad4158..ff4be7d5041 100644
--- a/providers/edge3/tests/unit/edge3/worker_api/routes/test_jobs.py
+++ b/providers/edge3/tests/unit/edge3/worker_api/routes/test_jobs.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import json
+from datetime import timedelta
 from pathlib import Path
 from typing import TYPE_CHECKING
 from unittest.mock import patch
@@ -27,10 +28,15 @@ from fastapi import HTTPException, status
 from sqlalchemy import delete, select
 
 from airflow.executors.workloads import BundleInfo, ExecuteTask
-from airflow.providers.common.compat.sdk import Stats
+from airflow.providers.common.compat.sdk import Stats, timezone
 from airflow.providers.edge3.models.edge_job import EdgeJobModel
 from airflow.providers.edge3.models.edge_worker import EdgeWorkerModel, 
EdgeWorkerState
-from airflow.providers.edge3.models.types import EXECUTE_CALLBACK_TAG
+from airflow.providers.edge3.models.types import (
+    CALLBACK_JOB_MAP_INDEX,
+    CALLBACK_JOB_TRY_NUMBER,
+    EXECUTE_CALLBACK_TAG,
+    build_callback_run_id,
+)
 from airflow.providers.edge3.worker_api.datamodels import WorkerQueuesBody
 from airflow.providers.edge3.worker_api.routes import jobs
 from airflow.providers.edge3.worker_api.routes.jobs import fetch, 
parse_command, state
@@ -309,6 +315,188 @@ class TestJobsApiRoutes:
                 },
             )
 
+    @patch(f"{Stats.__module__}.Stats.incr")
+    def test_fetch_returns_highest_priority_job_first(self, mock_stats_incr, 
session: Session):
+        with create_session() as session:
+            session.add(
+                EdgeWorkerModel(
+                    worker_name="worker1", state=EdgeWorkerState.IDLE, 
queues=[QUEUE], team_name=None
+                )
+            )
+            queued_dttm = timezone.datetime(2026, 1, 1)
+            for task_id, priority_weight, delay in [
+                ("low", 1, 0),
+                ("high", 100, 1),
+                ("medium_late", 10, 3),
+                ("medium_early", 10, 2),
+            ]:
+                session.add(
+                    EdgeJobModel(
+                        dag_id=DAG_ID,
+                        task_id=task_id,
+                        run_id=RUN_ID,
+                        try_number=1,
+                        map_index=-1,
+                        state=TaskInstanceState.QUEUED,
+                        queue=QUEUE,
+                        concurrency_slots=1,
+                        command=MOCK_COMMAND_STR,
+                        priority_weight=priority_weight,
+                        queued_dttm=queued_dttm + timedelta(seconds=delay),
+                    )
+                )
+            session.commit()
+
+            body = WorkerQueuesBody(free_concurrency=1, queues=[QUEUE], 
team_name=None)
+            fetched = [fetch("worker1", body, session) for _ in range(4)]
+
+            assert [job.task_id for job in fetched if job] == ["high", 
"medium_early", "medium_late", "low"]
+
+    @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="ExecuteCallback 
requires Airflow 3.3+")
+    @patch(f"{Stats.__module__}.Stats.incr")
+    def test_fetch_returns_callbacks_before_higher_priority_tasks(self, 
mock_stats_incr, session: Session):
+        """Callbacks are fetched in queue order regardless of their own 
priority_weight."""
+        queued_dttm = timezone.datetime(2026, 1, 1)
+        callbacks = [
+            ("11111111-1111-1111-1111-111111111111", 100, queued_dttm),
+            ("22222222-2222-2222-2222-222222222222", 1, queued_dttm - 
timedelta(seconds=1)),
+        ]
+        with create_session() as session:
+            session.add(
+                EdgeWorkerModel(
+                    worker_name="worker1", state=EdgeWorkerState.IDLE, 
queues=[QUEUE], team_name=None
+                )
+            )
+            for callback_id, priority_weight, callback_queued_dttm in 
callbacks:
+                callback = ExecuteCallback(
+                    callback=CallbackDTO(
+                        id=callback_id,
+                        fetch_method=CallbackFetchMethod.IMPORT_PATH,
+                        data={"path": "builtins.dict", "kwargs": {}},
+                    ),
+                    dag_rel_path=Path("test.py"),
+                    bundle_info=BundleInfo(name="test_bundle", version="1.0"),
+                    token="test_token",
+                    log_path="test.log",
+                )
+                session.add(
+                    EdgeJobModel(
+                        dag_id=EXECUTE_CALLBACK_TAG,
+                        task_id=callback_id,
+                        run_id=build_callback_run_id(callback_id),
+                        try_number=CALLBACK_JOB_TRY_NUMBER,
+                        map_index=CALLBACK_JOB_MAP_INDEX,
+                        state=TaskInstanceState.QUEUED,
+                        queue=QUEUE,
+                        concurrency_slots=1,
+                        command=callback.model_dump_json(),
+                        priority_weight=priority_weight,
+                        queued_dttm=callback_queued_dttm,
+                    )
+                )
+            session.add(
+                EdgeJobModel(
+                    dag_id=DAG_ID,
+                    task_id="high",
+                    run_id=RUN_ID,
+                    try_number=1,
+                    map_index=-1,
+                    state=TaskInstanceState.QUEUED,
+                    queue=QUEUE,
+                    concurrency_slots=1,
+                    command=MOCK_COMMAND_STR,
+                    priority_weight=100,
+                    queued_dttm=queued_dttm - timedelta(seconds=2),
+                )
+            )
+            session.commit()
+
+            body = WorkerQueuesBody(free_concurrency=1, queues=[QUEUE], 
team_name=None)
+            fetched = [fetch("worker1", body, session) for _ in range(3)]
+
+            assert [job.task_id for job in fetched if job] == 
[callbacks[1][0], callbacks[0][0], "high"]
+
+    @pytest.mark.parametrize(
+        ("dag_id", "run_id", "try_number", "map_index"),
+        [
+            pytest.param(
+                DAG_ID,
+                build_callback_run_id("low"),
+                CALLBACK_JOB_TRY_NUMBER,
+                CALLBACK_JOB_MAP_INDEX,
+                id="only-dag-id-differs",
+            ),
+            pytest.param(
+                EXECUTE_CALLBACK_TAG,
+                build_callback_run_id("other"),
+                CALLBACK_JOB_TRY_NUMBER,
+                CALLBACK_JOB_MAP_INDEX,
+                id="only-run-id-differs",
+            ),
+            pytest.param(
+                EXECUTE_CALLBACK_TAG,
+                build_callback_run_id("low"),
+                CALLBACK_JOB_TRY_NUMBER + 1,
+                CALLBACK_JOB_MAP_INDEX,
+                id="only-try-number-differs",
+            ),
+            pytest.param(
+                EXECUTE_CALLBACK_TAG,
+                build_callback_run_id("low"),
+                CALLBACK_JOB_TRY_NUMBER,
+                CALLBACK_JOB_MAP_INDEX + 1,
+                id="only-map-index-differs",
+            ),
+        ],
+    )
+    @patch(f"{Stats.__module__}.Stats.incr")
+    def test_fetch_needs_full_callback_identity_to_jump_the_queue(
+        self, mock_stats_incr, dag_id, run_id, try_number, map_index, session: 
Session
+    ):
+        queued_dttm = timezone.datetime(2026, 1, 1)
+        with create_session() as session:
+            session.add(
+                EdgeWorkerModel(
+                    worker_name="worker1", state=EdgeWorkerState.IDLE, 
queues=[QUEUE], team_name=None
+                )
+            )
+            session.add(
+                EdgeJobModel(
+                    dag_id=DAG_ID,
+                    task_id="high",
+                    run_id=RUN_ID,
+                    try_number=1,
+                    map_index=-1,
+                    state=TaskInstanceState.QUEUED,
+                    queue=QUEUE,
+                    concurrency_slots=1,
+                    command=MOCK_COMMAND_STR,
+                    priority_weight=100,
+                    queued_dttm=queued_dttm + timedelta(seconds=1),
+                )
+            )
+            session.add(
+                EdgeJobModel(
+                    dag_id=dag_id,
+                    task_id="low",
+                    run_id=run_id,
+                    try_number=try_number,
+                    map_index=map_index,
+                    state=TaskInstanceState.QUEUED,
+                    queue=QUEUE,
+                    concurrency_slots=1,
+                    command=MOCK_COMMAND_STR,
+                    priority_weight=1,
+                    queued_dttm=queued_dttm,
+                )
+            )
+            session.commit()
+
+            body = WorkerQueuesBody(free_concurrency=1, queues=[QUEUE], 
team_name=None)
+            fetched = [fetch("worker1", body, session) for _ in range(2)]
+
+            assert [job.task_id for job in fetched if job] == ["high", "low"]
+
     @patch(f"{Stats.__module__}.Stats.incr")
     def test_fetch_filters_by_worker_team_name(self, mock_stats_incr, session: 
Session):
         with create_session() as session:
@@ -469,7 +657,14 @@ class TestParseCommand:
         workload = self._make_execute_task()
         command_json = workload.model_dump_json()
 
-        result = parse_command(command_json, dag_id="test_dag", 
run_id="test_run")
+        result = parse_command(
+            command_json,
+            dag_id="test_dag",
+            task_id="test_task",
+            run_id="test_run",
+            try_number=1,
+            map_index=-1,
+        )
 
         assert isinstance(result, ExecuteTask)
         assert result.ti.dag_id == "test_dag"
@@ -479,10 +674,16 @@ class TestParseCommand:
         workload = self._make_execute_callback()
         command_json = workload.model_dump_json()
 
-        dag_id = EXECUTE_CALLBACK_TAG
-        run_id = f"{EXECUTE_CALLBACK_TAG}-{workload.callback.key}"
+        callback_id = workload.callback.id
 
-        result = parse_command(command_json, dag_id=dag_id, run_id=run_id)
+        result = parse_command(
+            command_json,
+            dag_id=EXECUTE_CALLBACK_TAG,
+            task_id=callback_id,
+            run_id=build_callback_run_id(callback_id),
+            try_number=CALLBACK_JOB_TRY_NUMBER,
+            map_index=CALLBACK_JOB_MAP_INDEX,
+        )
 
         assert isinstance(result, ExecuteCallback)
         assert result.callback.id == "12345678-1234-5678-1234-567812345678"
@@ -493,6 +694,13 @@ class TestParseCommand:
         workload = self._make_execute_task()
         command_json = workload.model_dump_json()
 
-        result = parse_command(command_json, dag_id="some_dag", 
run_id=f"{EXECUTE_CALLBACK_TAG}-something")
+        result = parse_command(
+            command_json,
+            dag_id="some_dag",
+            task_id="something",
+            run_id=build_callback_run_id("something"),
+            try_number=CALLBACK_JOB_TRY_NUMBER,
+            map_index=CALLBACK_JOB_MAP_INDEX,
+        )
 
         assert isinstance(result, ExecuteTask)

Reply via email to