From c94cf3175585f148091d7debb4e33901e8dd7b21 Mon Sep 17 00:00:00 2001 From: David Blain Date: Fri, 17 Jul 2026 13:03:09 +0200 Subject: [PATCH 01/86] Flag missing conn-fields when hook-only files change in static checks --- providers/.pre-commit-config.yaml | 5 +- scripts/ci/prek/check_provider_conn_fields.py | 12 ++-- scripts/ci/prek/check_provider_yaml_files.py | 32 ++++++++- ...st_check_conn_fields_match_form_widgets.py | 8 ++- uv.lock | 68 ++++++++++--------- 5 files changed, 85 insertions(+), 40 deletions(-) diff --git a/providers/.pre-commit-config.yaml b/providers/.pre-commit-config.yaml index 652477809de67..1c721ed3c4ad3 100644 --- a/providers/.pre-commit-config.yaml +++ b/providers/.pre-commit-config.yaml @@ -232,7 +232,10 @@ repos: name: Validate provider.yaml files entry: ../scripts/ci/prek/check_provider_yaml_files.py language: python - files: ^.*/provider\.yaml$ + files: > + (?x) + ^.*/provider\.yaml$| + ^.*/src/airflow/providers/.*/hooks/[^/]+\.py$ exclude: ^.*/.venv/.*$ require_serial: true - id: check-cli-definition-imports diff --git a/scripts/ci/prek/check_provider_conn_fields.py b/scripts/ci/prek/check_provider_conn_fields.py index 8d50c16335b01..9980095b83c1c 100644 --- a/scripts/ci/prek/check_provider_conn_fields.py +++ b/scripts/ci/prek/check_provider_conn_fields.py @@ -46,6 +46,11 @@ def check_conn_fields_for_entry( at all — the entry is then skipped entirely (no ``conn-fields`` check), or - raises any other ``Exception`` to signal an unexpected failure (converted here into an error string so callers never need to catch it). + + When the hook returns widgets, the ``conn-fields`` section in the provider YAML + must exactly match. If ``conn-fields`` is absent, every widget key is treated + as missing from YAML (because any custom field missing from ``conn-fields`` will + be invisible in the new React connection UI). """ hook_class_name: str = conn_type_entry["hook-class-name"] connection_type: str = conn_type_entry.get("connection-type", "?") @@ -61,11 +66,8 @@ def check_conn_fields_for_entry( if widget_keys is None: return [] - conn_fields = conn_type_entry.get("conn-fields") - if conn_fields is None: - # No conn-fields declared: the new UI simply exposes no custom fields for this - # connection type, which is intentional. Nothing to validate. - return [] + # Treat absent conn-fields as an empty dict so any hook widget is flagged as missing. + conn_fields: dict = conn_type_entry.get("conn-fields") or {} error = build_mismatch_error( set(conn_fields.keys()), widget_keys, connection_type, yaml_file_path, hook_class_name diff --git a/scripts/ci/prek/check_provider_yaml_files.py b/scripts/ci/prek/check_provider_yaml_files.py index 0c74234e06406..1f50d20be961f 100755 --- a/scripts/ci/prek/check_provider_yaml_files.py +++ b/scripts/ci/prek/check_provider_yaml_files.py @@ -23,6 +23,7 @@ # /// from __future__ import annotations +import pathlib import sys from common_prek_utils import ( @@ -33,7 +34,36 @@ initialize_breeze_prek(__name__, __file__) -files_to_test = sys.argv[1:] + +def _resolve_provider_yaml_files(raw_files: list[str]) -> list[str]: + """ + Accept a mix of provider.yaml paths and Python source files. + + When a Python source file is passed (e.g. a hook whose + ``get_connection_form_widgets()`` was edited), map it to the + ``provider.yaml`` at the root of the same provider package so the + conn-fields check runs even when only the hook changes. + + All paths are relative to the ``providers/`` directory, as supplied by + prek. The first path segment is the provider package name + (e.g. ``samba/src/airflow/...`` → ``samba/provider.yaml``). + """ + result: set[str] = set() + for f in raw_files: + p = pathlib.PurePosixPath(f) + if p.name == "provider.yaml": + result.add(f) + else: + # Map any Python file to the provider.yaml of its package root. + # Path structure: / + # e.g. samba/src/airflow/providers/samba/hooks/samba.py + parts = p.parts + if parts: + result.add(f"{parts[0]}/provider.yaml") + return sorted(result) + + +files_to_test = _resolve_provider_yaml_files(sys.argv[1:]) cmd_result = run_command_via_breeze_run( ["python3", "/opt/airflow/scripts/in_container/run_provider_yaml_files_check.py", *files_to_test], backend="sqlite", diff --git a/scripts/tests/ci/prek/test_check_conn_fields_match_form_widgets.py b/scripts/tests/ci/prek/test_check_conn_fields_match_form_widgets.py index db63f6783aaf5..271bd9a88c6d3 100644 --- a/scripts/tests/ci/prek/test_check_conn_fields_match_form_widgets.py +++ b/scripts/tests/ci/prek/test_check_conn_fields_match_form_widgets.py @@ -107,8 +107,8 @@ class TestCheckConnFieldsForEntry: pytest.param([], _get_keys(), id="empty-on-both-sides"), pytest.param(["a"], _skip, id="skip-hook-without-get-connection-form-widgets"), pytest.param(None, _skip, id="skip-missing-conn-fields-when-hook-has-no-widgets"), - # Hook with widgets but no conn-fields is allowed: new UI intentionally omits custom fields. - pytest.param(None, _get_keys("field_a"), id="no-conn-fields-with-hook-widgets-is-ok"), + # When hook has no custom widgets, absent conn-fields is fine. + pytest.param(None, lambda _: set(), id="no-conn-fields-no-hook-widgets-is-ok"), ], ) def test_no_errors(self, conn_fields, get_keys): @@ -123,6 +123,10 @@ def test_no_errors(self, conn_fields, get_keys): ), pytest.param(["a"], _raise, "boom", id="unexpected-exception-message"), pytest.param(["a"], _raise, HOOK_CLASS, id="unexpected-exception-mentions-hook-class"), + # absent conn-fields with hook widgets must be flagged (every widget key is "missing") + pytest.param( + None, _get_keys("auth_protocol"), "auth_protocol", id="absent-conn-fields-with-hook-widgets" + ), ], ) def test_one_error_containing(self, conn_fields, get_keys, expected_in_error): diff --git a/uv.lock b/uv.lock index dc07848533e42..c7dd422263b6f 100644 --- a/uv.lock +++ b/uv.lock @@ -647,7 +647,7 @@ name = "aiohttp-cors" version = "0.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp" }, + { name = "aiohttp", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/d89e846a5444b3d5eb8985a6ddb0daef3774928e1bfbce8e84ec97b0ffa7/aiohttp_cors-0.8.1.tar.gz", hash = "sha256:ccacf9cb84b64939ea15f859a146af1f662a6b1d68175754a07315e305fb1403", size = 38626, upload-time = "2025-03-31T14:16:20.048Z" } wheels = [ @@ -4813,6 +4813,9 @@ dependencies = [ ] [package.optional-dependencies] +amazon = [ + { name = "apache-airflow-providers-amazon" }, +] avro = [ { name = "fastavro" }, ] @@ -4842,6 +4845,7 @@ standard = [ dev = [ { name = "apache-airflow" }, { name = "apache-airflow-devel-common" }, + { name = "apache-airflow-providers-amazon" }, { name = "apache-airflow-providers-common-compat" }, { name = "apache-airflow-providers-common-sql", extra = ["pandas", "polars"] }, { name = "apache-airflow-providers-databricks", extra = ["sqlalchemy"] }, @@ -4860,6 +4864,7 @@ docs = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.14.0,<4" }, { name = "apache-airflow", editable = "." }, + { name = "apache-airflow-providers-amazon", marker = "extra == 'amazon'", editable = "providers/amazon" }, { name = "apache-airflow-providers-common-compat", editable = "providers/common/compat" }, { name = "apache-airflow-providers-common-sql", editable = "providers/common/sql" }, { name = "apache-airflow-providers-fab", marker = "extra == 'fab'", editable = "providers/fab" }, @@ -4882,12 +4887,13 @@ requires-dist = [ { name = "pyarrow", marker = "python_full_version >= '3.14'", specifier = ">=22.0.0" }, { name = "requests", specifier = ">=2.32.0,<3" }, ] -provides-extras = ["avro", "azure-identity", "fab", "google", "sdk", "standard", "openlineage", "sqlalchemy"] +provides-extras = ["avro", "amazon", "azure-identity", "fab", "google", "sdk", "standard", "openlineage", "sqlalchemy"] [package.metadata.requires-dev] dev = [ { name = "apache-airflow", editable = "." }, { name = "apache-airflow-devel-common", editable = "devel-common" }, + { name = "apache-airflow-providers-amazon", editable = "providers/amazon" }, { name = "apache-airflow-providers-common-compat", editable = "providers/common/compat" }, { name = "apache-airflow-providers-common-sql", editable = "providers/common/sql" }, { name = "apache-airflow-providers-common-sql", extras = ["pandas", "polars"], editable = "providers/common/sql" }, @@ -10982,7 +10988,7 @@ name = "colorful" version = "0.5.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "python_full_version < '3.15' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/31/109ef4bedeb32b4202e02ddb133162457adc4eb890a9ed9c05c9dd126ed0/colorful-0.5.8.tar.gz", hash = "sha256:bb16502b198be2f1c42ba3c52c703d5f651d826076817185f0294c1a549a7445", size = 209361, upload-time = "2025-10-29T11:53:21.663Z" } wheels = [ @@ -17521,9 +17527,9 @@ name = "opencensus" version = "0.11.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-api-core" }, - { name = "opencensus-context" }, - { name = "six" }, + { name = "google-api-core", marker = "python_full_version < '3.15'" }, + { name = "opencensus-context", marker = "python_full_version < '3.15'" }, + { name = "six", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/15/a7/a46dcffa1b63084f9f17fe3c8cb20724c4c8f91009fd0b2cfdb27d5d2b35/opencensus-0.11.4.tar.gz", hash = "sha256:cbef87d8b8773064ab60e5c2a1ced58bbaa38a6d052c41aec224958ce544eff2", size = 64966, upload-time = "2024-01-03T18:04:07.085Z" } wheels = [ @@ -20822,14 +20828,14 @@ name = "ray" version = "2.56.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "filelock" }, - { name = "jsonschema" }, - { name = "msgpack" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "pyyaml" }, - { name = "requests" }, + { name = "click", marker = "python_full_version < '3.15'" }, + { name = "filelock", marker = "python_full_version < '3.15'" }, + { name = "jsonschema", marker = "python_full_version < '3.15'" }, + { name = "msgpack", marker = "python_full_version < '3.15'" }, + { name = "packaging", marker = "python_full_version < '3.15'" }, + { name = "protobuf", marker = "python_full_version < '3.15'" }, + { name = "pyyaml", marker = "python_full_version < '3.15'" }, + { name = "requests", marker = "python_full_version < '3.15'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c0/d6ffbe8ae2e2e10ea07aa08ea2dd35597239f0b3faaa29b5d7bbc405ab32/ray-2.56.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:f34b2345a47ad144292c1b34eeba2ed8d556078f7bd118d1adf2090d5199c843", size = 66362009, upload-time = "2026-06-29T20:50:14.442Z" }, @@ -20854,20 +20860,20 @@ wheels = [ [package.optional-dependencies] default = [ - { name = "aiohttp" }, - { name = "aiohttp-cors" }, - { name = "colorful" }, - { name = "grpcio" }, - { name = "opencensus" }, - { name = "opentelemetry-exporter-prometheus" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "prometheus-client" }, - { name = "py-spy" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "smart-open" }, - { name = "virtualenv" }, + { name = "aiohttp", marker = "python_full_version < '3.15'" }, + { name = "aiohttp-cors", marker = "python_full_version < '3.15'" }, + { name = "colorful", marker = "python_full_version < '3.15'" }, + { name = "grpcio", marker = "python_full_version < '3.15'" }, + { name = "opencensus", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-exporter-prometheus", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-proto", marker = "python_full_version < '3.15'" }, + { name = "opentelemetry-sdk", marker = "python_full_version < '3.15'" }, + { name = "prometheus-client", marker = "python_full_version < '3.15'" }, + { name = "py-spy", marker = "python_full_version < '3.15'" }, + { name = "pydantic", marker = "python_full_version < '3.15'" }, + { name = "requests", marker = "python_full_version < '3.15'" }, + { name = "smart-open", marker = "python_full_version < '3.15'" }, + { name = "virtualenv", marker = "python_full_version < '3.15'" }, ] [[package]] @@ -21964,8 +21970,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "python_full_version >= '3.14' or platform_machine != 'arm64' or sys_platform != 'darwin'" }, - { name = "jeepney", marker = "python_full_version >= '3.14' or platform_machine != 'arm64' or sys_platform != 'darwin'" }, + { name = "cryptography", marker = "(python_full_version >= '3.14' and sys_platform == 'darwin') or (python_full_version < '3.15' and sys_platform == 'emscripten') or (python_full_version < '3.15' and sys_platform == 'win32') or (platform_machine != 'arm64' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "jeepney", marker = "(python_full_version >= '3.14' and sys_platform == 'darwin') or (python_full_version < '3.15' and sys_platform == 'emscripten') or (python_full_version < '3.15' and sys_platform == 'win32') or (platform_machine != 'arm64' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -22164,7 +22170,7 @@ name = "smart-open" version = "8.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wrapt" }, + { name = "wrapt", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/3e/79fd5fd2375a8a500b9ec2f6a0762fc1ac33e35582d4a87483a78d19408f/smart_open-8.0.0.tar.gz", hash = "sha256:5a2008d60688bd3b33c52e2ef666d3c60cf956e73e215de8c7b242cf56fdd1b2", size = 61520, upload-time = "2026-06-27T16:28:11.894Z" } wheels = [ From 6113d168f29dfa2f12dc3cd2cf38fd686d1294d8 Mon Sep 17 00:00:00 2001 From: Vincent Kling Date: Fri, 17 Jul 2026 10:39:58 +0200 Subject: [PATCH 02/86] Add missing Dutch translations (#68742) * Add missing Dutch translations --- .../ui/public/i18n/locales/nl/assets.json | 17 +++ .../ui/public/i18n/locales/nl/browse.json | 18 +++ .../ui/public/i18n/locales/nl/common.json | 64 ++++++++--- .../ui/public/i18n/locales/nl/components.json | 24 ++-- .../ui/public/i18n/locales/nl/dag.json | 105 +++++++++++++++--- .../ui/public/i18n/locales/nl/dags.json | 25 +++-- .../ui/public/i18n/locales/nl/dashboard.json | 14 +++ .../ui/public/i18n/locales/nl/hitl.json | 14 +++ 8 files changed, 229 insertions(+), 52 deletions(-) diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/nl/assets.json b/airflow-core/src/airflow/ui/public/i18n/locales/nl/assets.json index d1e5c14474902..e6cbf2a650085 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/nl/assets.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/nl/assets.json @@ -2,6 +2,22 @@ "additional_data": "Extra data", "asset_many": "Assets", "asset_one": "Asset", + "assetStateStore": { + "add": "Voeg Asset State Store toe", + "clearAll": { + "resource": "Alle Asset State Stores", + "title": "Wis alle Asset State Stores", + "warning": "Alle Asset State Stores zullen worden gewist. Taken die deze store gebruiken om werk te coördineren, verliezen hun opgeslagen gegevens." + }, + "delete": "Verwijder Asset State Store", + "deleteWarning": "De asset zal deze opgeslagen store-invoer verliezen.", + "edit": "Bewerk Asset State Store", + "emptyState": "Asset state store slaat waarden op gescoped aan een asset identiteit, gedeeld over alle Dag runs. Workers kunnen naar de Asset State Store schrijven via de Task SDK.", + "lastUpdatedBy": "Laatst bijgewerkt door", + "lastUpdatedByApi": "API", + "lastUpdatedByWatcher": "Watcher", + "title": "Asset State Store" + }, "consumingDags": "Consumerende Dags", "consumingTasks": "Consumerende taken", "createEvent": { @@ -25,6 +41,7 @@ }, "title": "Asset Event voor {{name}} aanmaken" }, + "events": "Events", "extra": "Extra", "group": "Groep", "lastAssetEvent": "Laatste Asset Event", diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/nl/browse.json b/airflow-core/src/airflow/ui/public/i18n/locales/nl/browse.json index 134ef8fee798f..ddd2ece18369f 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/nl/browse.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/nl/browse.json @@ -11,6 +11,24 @@ }, "title": "Audit Log" }, + "deadlines": { + "columns": { + "alertName": "Alertnaam", + "deadlineTime": "Deadline tijd", + "status": "Status" + }, + "deadline_one": "Deadline", + "deadline_other": "Deadlines", + "filters": { + "status": "Status", + "statusOptions": { + "all": "Alles", + "missed": "Gemist", + "pending": "In afwachting" + } + }, + "title": "Deadlines" + }, "xcom": { "add": { "error": "Mislukt om XCom toe te voegen", diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/nl/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/nl/common.json index d4a33adbda7ad..f27c79562753b 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/nl/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/nl/common.json @@ -18,10 +18,14 @@ "asset_other": "Assets", "assetEvent_one": "Asset Event", "assetEvent_other": "Asset Events", + "assetInactive": { + "tooltip": "Upstream asset is gedeactiveerd; de scheduler houdt partitie-evaluatie vast tot de asset het opnieuw wordt geactiveerd." + }, "backfill_one": "Backfill", "backfill_other": "Backfills", "browse": { "auditLog": "Audit Log", + "deadlines": "Deadlines", "jobs": "Jobs", "requiredActions": "Vereiste acties", "xcoms": "XComs" @@ -47,7 +51,7 @@ "latestDagVersion": "Laatste Dag versie", "latestRun": "Laatste Run", "maxActiveRuns": "Maximaal aantal actieve Runs", - "maxActiveTasks": "Maximaal aantal actieve Tasks", + "maxActiveTasks": "Maximaal aantal actieve taken", "maxConsecutiveFailedDagRuns": "Maximaal aantal opeenvolgend mislukte Dag Runs", "nextRun": "Volgende Run", "owner": "Eigenaar", @@ -141,7 +145,11 @@ "selectDateRange": "Selecteer datumbereik", "startTime": "Starttijd" }, + "fullscreen": { + "tooltip": "Druk op {{hotkey}} voor volledig scherm" + }, "generateToken": "Genereer token", + "key": "Sleutel", "logicalDate": "Logische datum", "logout": "Uitloggen", "logoutConfirmation": "Je staat op het punt om uit te loggen uit de applicatie.", @@ -171,10 +179,14 @@ "note": { "add": "Notitie toevoegen", "dagRun": "Dag Run notitie", + "edit": "Bewerk notitie", "label": "Notitie", "placeholder": "Voeg een notitie toe...", - "taskInstance": "Task Instance notitie" + "preview": "Voorbeeld", + "taskInstance": "Taakinstantie notitie", + "write": "Schrijf" }, + "overallStatus": "Algemene status", "partitionedDagRun_one": "Gepartitioneerde Dag Run", "partitionedDagRun_other": "Gepartitioneerde Dag Runs", "partitionedDagRunDetail": { @@ -198,6 +210,12 @@ }, "tooltip": "Druk op {{hotkey}} om te scrollen naar {{direction}}" }, + "search": { + "advanced": { + "description": "Zoek overal in de waarde (substring zoekopdracht). Langzamer bij grote deployments omdat het de standaard B-tree index niet kan gebruiken. Bekijk de documentatie sectie over aangepaste metadata-indexen voor details.", + "title": "Zoek overal" + } + }, "security": { "actions": "Acties", "permissions": "Permissies", @@ -219,6 +237,7 @@ "startDate": "Start datum", "state": "Status", "states": { + "awaiting_input": "Wachtend op invoer", "deferred": "Uitgesteld", "failed": "Mislukt", "no_status": "Geen status", @@ -237,36 +256,47 @@ "upstream_failed": "Upstream mislukt" }, "table": { - "completedAt": "Completed at", - "createdAt": "Created at", + "completedAt": "Voltooid op", + "createdAt": "Aangemaakt op", "filterByTag": "Filter Dags op label", "filterColumns": "Filter tabel kolommen", "filterReset_one": "Reset filter", "filterReset_other": "Reset filters", "from": "Van", - "maxActiveRuns": "Maximaal aantal active Runs", + "maxActiveRuns": "Maximaal aantal actieve Runs", "noTagsFound": "Geen labels gevonden", "tagMode": { "all": "All", "any": "Any" }, "tagPlaceholder": "Filter op label", - "to": "Naar" + "to": "Naar", + "updatedAt": "Bijgewerkt op" }, "task": { - "documentation": "Task documentatie", - "lastInstance": "Laatste Task Instance", + "dependsOnPast": "Afhankelijk van vorige", + "documentation": "Taak documentatie", + "lastInstance": "Laatste taakinstantie", "operator": "Operator", - "triggerRule": "Trigger regel" + "retries": "Pogingen", + "triggerRule": "Trigger regel", + "waitForDownstream": "Wacht op downstream" + }, + "task_one": "Taak", + "task_other": "Taken", + "taskGroup": { + "documentation": "Taaakgroep documentatie" }, - "task_one": "Task", - "task_other": "Tasks", - "taskId": "Task ID", + "taskGroup_one": "Taakgroep", + "taskGroup_other": "Taakgroepen", + "taskId": "Taak ID", "taskInstance": { + "additionalAttributes": "Extra taakinstantie attributen", "dagVersion": "Dag versie", "executor": "Executor", "executorConfig": "Executor Configuratie", "hostname": "Hostname", + "id": "ID", "maxTries": "Maximaal aantal pogingen", "pid": "PID", "pool": "Pool", @@ -274,19 +304,22 @@ "priorityWeight": "Prioriteitsgewicht", "queue": "Wachtrij", "queuedWhen": "In de wachtrij gezet om", + "renderedMapIndex": "Gerenderde Map index", "scheduledWhen": "Gepland om", + "trigger": "Trigger", "triggerer": { - "assigned": "Toegewegen Triggerer", + "assigned": "Toegewezen Triggerer", "class": "Trigger class", "createdAt": "Trigger aangemaakt op", "id": "Trigger ID", + "job": "Triggerer Job", "latestHeartbeat": "Laatste Triggerer hartslag", "title": "Triggerer informatie" }, "unixname": "Unix naam" }, - "taskInstance_one": "Task Instance", - "taskInstance_other": "Task Instances", + "taskInstance_one": "Taakinstantie", + "taskInstance_other": "Taakinstanties", "timeRange": { "last12Hours": "Laatste 12 uur", "last24Hours": "Laatste 24 uur", @@ -376,6 +409,7 @@ "mustBeAtLeast": "Moet minimaal {{min}} zijn.", "mustBeValidNumber": "Moet een geldig nummer zijn." }, + "value": "Waarde", "wrap": { "hotkey": "w", "tooltip": "Druk op {{hotkey}} om wrap te schakelen", diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/nl/components.json b/airflow-core/src/airflow/ui/public/i18n/locales/nl/components.json index fb0a3f77950e4..763be83980afe 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/nl/components.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/nl/components.json @@ -10,9 +10,12 @@ "maxRuns": "Maximaal aantal actieve Runs", "missingAndErroredRuns": "Missende en mislukte Runs", "missingRuns": "Missende Runs", + "overrideExistingParams": "Overschrijf parameters op bestaande runs", "permissionDenied": "Dry Run mislukt: Gebruiker heeft geen toestemming om backfills te maken.", "reprocessBehavior": "Reprocess gedrag", "run": "Run Backfill", + "scheduleNotBackfillable": "Het schema van deze Dag ondersteunt geen backfills", + "schedulerPriorityHint": "Backfill Dag runs worden geordend na niet-backfill Dag runs in elke scheduler cyclus. Backfill runs kunnen langer in de wachtrij blijven als er andere niet-backfill runs aanwezig zijn.", "selectDescription": "Draai deze Dag voor een geselecteerd datumbereik", "selectLabel": "Backfill", "title": "Run Backfill", @@ -57,11 +60,11 @@ } }, "durationChart": { - "duration": "Duur (seconds)", + "duration": "Duur (seconden)", "lastDagRun_one": "Laatste Dag Run", "lastDagRun_other": "Laatste {{count}} Dag Runs", - "lastTaskInstance_one": "Laatste Task Instance", - "lastTaskInstance_other": "Laatste {{count}} Task Instances", + "lastTaskInstance_one": "Laatste taakinstantie", + "lastTaskInstance_other": "Laatste {{count}} taakinstanties", "queuedDuration": "Duur in de wachtrij", "runAfter": "Run na", "runDuration": "Run duur" @@ -71,13 +74,15 @@ "files_other": "{{count}} bestanden" }, "flexibleForm": { + "durationPlaceholder": "Voer duur in ISO 8601 formaat in", "placeholder": "Selecteer waarde", "placeholderArray": "Voer elke string in op een nieuwe regel", "placeholderExamples": "Start met typen om opties te zien", - "placeholderMulti": "Selecteer een of meerder waarden", + "placeholderMulti": "Selecteer een of meerdere waarden", "validationErrorArrayNotArray": "Waarde moet een array zijn.", "validationErrorArrayNotNumbers": "Alle elementen in de array moeten nummers zijn.", "validationErrorArrayNotObject": "Alle elementen in de array moeten objecten zijn.", + "validationErrorDuration": "Ongeldig ISO 8601 duur-formaat", "validationErrorRequired": "Dit veld is verplicht" }, "graph": { @@ -89,9 +94,10 @@ "downloadImageError": "Mislukt om de afbeelding van de grafiek te downloaden.", "downloadImageErrorTitle": "Download mislukt", "otherDagRuns": "+Andere Dag Runs", - "taskCount_one": "{{count}} Task", - "taskCount_other": "{{count}} Tasks", - "taskGroup": "Task Group" + "taskCount_one": "{{count}} taak", + "taskCount_other": "{{count}} taken", + "taskGroup": "Taakgroep", + "zoomToTask": "Zoom naar geselecteerde taak" }, "limitedList": "+{{count}} meer", "limitedList.allItems": "Alle {{count}} items:", @@ -110,8 +116,8 @@ "sortedAscending": "oplopend gesorteerd", "sortedDescending": "aflopend gesorteerd", "sortedUnsorted": "ongesorteerd", - "taskTries": "Task pogingen", - "taskTryPlaceholder": "Task poging", + "taskTries": "Taakpogingen", + "taskTryPlaceholder": "Taakpoging", "team": { "selector": { "helperText": "Optioneel. Beperk het gebruik tot een specifiek team.", diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/nl/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/nl/dag.json index b146e35ec870d..fda29c0f75fec 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/nl/dag.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/nl/dag.json @@ -37,12 +37,40 @@ "code": { "bundleUrl": "Bundle URL", "noCode": "Geen code gevonden", - "parseDuration": "Parse duur:", + "parseDuration": "Vewrwerkingsduur:", "parsedAt": "Geparsed op:" }, + "deadlineAlerts": { + "completionRule": "Moet worden voltooid binnen {{interval}} van {{reference}}", + "count_one": "{{count}} deadline", + "count_other": "{{count}} deadlines", + "referenceType": { + "AverageRuntimeDeadline": "Gemiddelde uitvoertijd", + "DagRunLogicalDateDeadline": "Logische datum", + "DagRunQueuedAtDeadline": "Wachttijd" + } + }, + "deadlineStatus": { + "actual": "Werkelijk", + "expected": "Verwacht", + "finishedEarly": "Voltooid {{duration}} voor de deadline", + "finishedLate": "Voltooid {{duration}} na de deadline", + "label": "Deadline", + "met": "Behaald", + "missed": "Gemist", + "missedCount_one": "{{count}} Gemiste deadline", + "missedCount_other": "{{count}} Gemiste deadlines", + "mixedCount": "{{missedCount}} gemist, {{upcomingCount}} opkomend", + "stillRunning": "Nog bezig", + "upcoming": "Opkomend", + "upcomingCount_one": "{{count}} opkomende deadline", + "upcomingCount_other": "{{count}} opkomende deadlines" + }, "extraLinks": "Extra Links", "grid": { "buttons": { + "newerRuns": "Nieuwere runs", + "olderRuns": "Oudere runs", "resetToLatest": "Reset naar laatste", "toggleGroup": "Groepen omschakelen" }, @@ -52,6 +80,9 @@ "buttons": { "advanced": "Geavanceerd", "dagDocs": "Dag Docs" + }, + "status": { + "deactivated": "Gedeactiveerd" } }, "logs": { @@ -60,18 +91,20 @@ "critical": "CRITICAL", "debug": "DEBUG", "error": "ERROR", - "fullscreen": { - "button": "Volledig scherm", - "tooltip": "Druk {{hotkey}} voor volledig scherm" - }, "info": "INFO", "noTryNumber": "Geen poging nummer", + "search": { + "matchCount": "{{current}} van {{total}}", + "noMatches": "Geen overeenkomsten", + "placeholder": "Zoek in logs..." + }, "settings": "Loginstellingen", - "viewInExternal": "Bekijk logs in {{name}} (attempt {{attempt}})", + "viewInExternal": "Bekijk logs in {{name}} (poging {{attempt}})", "warning": "WARNING" }, "navigation": { "navigation": "Navigatie: {{arrow}}", + "openGraphFilters": "Taakfilters: Ctrl+Shift+F", "toggleGroup": "Groep in-/uitklappen: Spatie" }, "notFound": { @@ -84,19 +117,23 @@ "buttons": { "failedRun_one": "Mislukte Run", "failedRun_other": "Mislukte Runs", - "failedTask_one": "Mislukte Task", - "failedTask_other": "Mislukte Tasks", - "failedTaskInstance_one": "Mislukte Task Instance", - "failedTaskInstance_other": "Mislukte Task Instances" + "failedTask_one": "Mislukte taak", + "failedTask_other": "Mislukte taken", + "failedTaskInstance_one": "Mislukte taakinstantie", + "failedTaskInstance_other": "Mislukte taakinstanties" }, "charts": { "assetEvent_one": "Aangemaakte Asset Event", "assetEvent_other": "Aangemaakte Asset Events" }, + "deadlines": { + "showAll": "Toon alles", + "title": "Deadlines" + }, "failedLogs": { "hideLogs": "Verberg logs", "showLogs": "Toon logs", - "title": "Recente mislukte Task logs", + "title": "Recente mislukte taaklogs", "viewFullLogs": "Bekijk volledige logs" } }, @@ -115,13 +152,23 @@ "options": { "allDagDependencies": "Alle Dag afhankelijkheden", "externalConditions": "Externe omstandigheden", - "onlyTasks": "Alleen Rasks" + "onlyTasks": "Alleen taken" }, "placeholder": "Afhankelijkheden" }, "graphDirection": { "label": "Richting van de grafiek" }, + "graphFilters": { + "clearFilters": "Wis filters", + "durationGte": "Minimale duur (s)", + "durationGteHint": "Voor gemapte taken, meet de totale duur over alle instanties", + "mapIndex": "Min. map index", + "mapIndexHint": "Toont gemapte taken uitgebreid tot ten minste deze index", + "selectStatus": "Selecteer status", + "selectTaskGroup": "Selecteer taakgroep", + "title": "Taakfilters" + }, "showVersionIndicator": { "label": "Toon versie-indicator", "options": { @@ -173,18 +220,40 @@ "code": "Code", "details": "Details", "logs": "Logs", - "mappedTaskInstances_one": "Task Instance [{{count}}]", - "mappedTaskInstances_other": "Task Instances [{{count}}]", + "mappedTaskInstances_one": "Taakinstantie [{{count}}]", + "mappedTaskInstances_other": "Taakinstanties [{{count}}]", "overview": "Overzicht", "renderedTemplates": "Gerenderde templates", "requiredActions": "Vereiste acties", "runs": "Runs", - "taskInstances": "Task Instances", - "tasks": "Tasks", + "storage": "Opslag", + "taskInstances": "Taakinstanties", + "taskStateStore": "Task State Store", + "tasks": "Taken", "xcom": "XCom" }, "taskGroups": { - "collapseAll": "Alle Task Groups inklappen", - "expandAll": "Alle Task Groups uitklappen" + "collapseAll": "Alle taakgroepen inklappen", + "expandAll": "Alle taakgroepen uitklappen" + }, + "taskStateStore": { + "add": "Voeg Task State Store toe", + "clearAll": { + "resource": "Alle Task State Stores", + "title": "Wis alle Task State Stores", + "warning": "Alle Task State Stores zullen worden gewist. Taken die deze store gebruiken om extern werk bij te houden, kunnen niet worden hervat zonder opnieuw te starten vanaf het begin." + }, + "delete": "Verwijder Task State Store", + "deleteWarning": "De taak zal dit persistente geheugen verliezen. Als de taak deze sleutel gebruikt om extern werk bij te houden (bijv. een extern job-ID), kan het niet worden hervat.", + "edit": "Wijzig Task State Store", + "emptyStore": "Task state store slaat waarden op die persistent blijven over retries. Workers kunnen task state store schrijven via de Task SDK.", + "expiresAt": { + "column": "Verloopt op", + "custom": "Aangepast", + "default": "Standaard ({{interval}})", + "label": "Verloopdatum", + "never": "Nooit" + }, + "title": "Task State Store" } } diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/nl/dags.json b/airflow-core/src/airflow/ui/public/i18n/locales/nl/dags.json index e349aeb471c80..1e52b6075fb53 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/nl/dags.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/nl/dags.json @@ -25,8 +25,8 @@ "ownerLink": "Eigenaarslink voor {{owner}}", "runAndTaskActions": { "affectedTasks": { - "noItemsFound": "Geen Tasks gevonden.", - "title": "Affected Tasks: {{count}}" + "noItemsFound": "Geen taken gevonden.", + "title": "Beïnvloede taken: {{count}}" }, "clear": { "button": "Wis {{type}}", @@ -34,9 +34,14 @@ "error": "Mislukt om {{type}} te wissen", "title": "Wis {{type}}" }, + "clearAllMapped": { + "button": "Wis alle gemapte taken", + "buttonTooltip": "Druk op Shift+C om alle gemapte taakinstanties te wissen", + "title": "Wis alle gemapte taakinstanties" + }, "confirmationDialog": { - "description": "Taak is momenteel in een {{state}} status gestart door gebruiker {{user}} op {{time}}. \nDe gebruiker kan deze taak niet wissen totdat deze klaar is met uitvoeren of een gebruiker de optie \"Voorkom opnieuw uitvoeren als task bezig is\" in het dialoogvenster voor het wissen van taken uitschakelt.", - "title": "Kan Task Instance niet wissen" + "description": "Taak is momenteel in een {{state}} status gestart door gebruiker {{user}} op {{time}}. \nDe gebruiker kan deze taak niet wissen totdat deze klaar is met uitvoeren of een gebruiker de optie \"Voorkom opnieuw uitvoeren als taak bezig is\" in het dialoogvenster voor het wissen van taken uitschakelt.", + "title": "Kan taakinstanties niet wissen" }, "delete": { "button": "Verwijder {{type}}", @@ -52,7 +57,7 @@ } }, "markAs": { - "button": "Makeer {{type}} als ...", + "button": "Markeer {{type}} als ...", "buttonTooltip": { "failed": "Druk op Shift+F om te markeren als mislukt", "success": "Druk op Shift+S om te markeren als succesvol" @@ -61,12 +66,12 @@ }, "options": { "downstream": "Downstream", - "existingTasks": "Wis bestaande Tasks", + "existingTasks": "Wis bestaande taken", "future": "Toekomst", - "onlyFailed": "Wis enkel mislukte Tasks", + "onlyFailed": "Wis enkel mislukte taken", "past": "Verleden", - "preventRunningTasks": "Voorkom opnieuw uitvoeren als task bezig is", - "queueNew": "Zet nieuwe Tasks in de wachtrij", + "preventRunningTasks": "Voorkom opnieuw uitvoeren als taak bezig is", + "queueNew": "Zet nieuwe taken in de wachtrij", "runOnLatestVersion": "Voer uit met de nieuwste bundelversie", "upstream": "Upstream" } @@ -76,7 +81,7 @@ "clear": "Zoekopdracht wissen", "dags": "Zoek Dags", "hotkey": "+K", - "tasks": "Zoek Tasks" + "tasks": "Zoek taken" }, "sort": { "displayName": { diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/nl/dashboard.json b/airflow-core/src/airflow/ui/public/i18n/locales/nl/dashboard.json index 62b0c6953649f..650290ed0f757 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/nl/dashboard.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/nl/dashboard.json @@ -1,4 +1,18 @@ { + "deadlines": { + "pending": { + "empty": "Geen aankomende deadlines", + "title": "Aankomende deadlines" + }, + "recentlyMissed": { + "empty": "Geen gemiste deadlines", + "title": "Gemiste deadlines" + }, + "showMore": "Toon meer", + "title": "Deadlines" + }, + "deferredSlotsNotCounted": "Uitgestelde taken niet meegeteld als sloten: {{count}}", + "deferredSlotsNotCountedTooltip": "Uitgestelde taken die in de balk worden weergegeven, worden meegeteld in de poolsloten. Uitgestelde taken die onder de balk worden weergegeven, zijn afkomstig van pools die uitgestelde taken niet in de sloten meetellen.", "favorite": { "favoriteDags_one": "Eerste {{count}} favoriete Dags", "favoriteDags_other": "Eerste {{count}} favoriete Dags", diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/nl/hitl.json b/airflow-core/src/airflow/ui/public/i18n/locales/nl/hitl.json index 2748dff18c2d4..03ae3a04a4150 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/nl/hitl.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/nl/hitl.json @@ -24,6 +24,20 @@ "success": "{{taskId}} reactie succesvol", "title": "Human Task Instance - {{taskId}}" }, + "review": { + "detail": { + "selectRequiredAction": "Selecteer een vereiste actie om details te zien" + }, + "list": { + "completedRequiredActions": "Voltooide vereiste acties ({{count}})", + "loadError": "Kan vereiste acties niet laden", + "loadingActions": "Vereiste acties laden...", + "pendingRequiredActions": "In afwachting van vereiste acties ({{count}})" + }, + "openReviewDrawer": "Open Review Drawer", + "pageLimitHint": "Alleen de eerste paar acties worden hier weergegeven. Gebruik \"$t(review.viewAll)\" om ze allemaal te zien.", + "viewAll": "Bekijk alle vereiste acties" + }, "state": { "approvalReceived": "Goedkeuring ontvangen", "approvalRequired": "Goedkeuring vereist", From f3f0901ca2858ce0683225484141950fcd34a7ff Mon Sep 17 00:00:00 2001 From: Kaxil Naik Date: Fri, 17 Jul 2026 12:52:50 +0100 Subject: [PATCH 03/86] Fix common.ai durable execution skipping Toolset-capability tools (#69881) When durable=True, tools supplied via a pydantic-ai Toolset capability (capabilities=[Toolset(ts)]) bypassed the CachingToolset applied to toolsets=, so their results re-executed on every retry instead of replaying. Wrap the inner toolset of concrete Toolset capabilities with the same CachingToolset. A Toolset capability backed by a callable factory can't be wrapped (it resolves per run) and now logs a warning. Also fixes two latent durable-execution bugs found in review: - cleanup() ran before the message-history XCom push and output serialization. A failure in either wiped the cache, so the retry re-ran every already-completed step. Move cleanup to after them. - the pre-3.3 ObjectStorage cache filename joined dag/task/run with "_", aliasing distinct tasks (dag "etl"/task "load_data" and dag "etl_load"/task "data" both mapped to one file, so one task could read, overwrite, or delete another's cache). Hash the identity components instead. --- .../providers/common/ai/durable/storage.py | 16 ++- .../providers/common/ai/operators/agent.py | 85 +++++++++-- .../unit/common/ai/durable/test_storage.py | 29 ++-- .../unit/common/ai/operators/test_agent.py | 132 +++++++++++++++++- 4 files changed, 239 insertions(+), 23 deletions(-) diff --git a/providers/common/ai/src/airflow/providers/common/ai/durable/storage.py b/providers/common/ai/src/airflow/providers/common/ai/durable/storage.py index d50107631a4a1..0ff1c84afbe75 100644 --- a/providers/common/ai/src/airflow/providers/common/ai/durable/storage.py +++ b/providers/common/ai/src/airflow/providers/common/ai/durable/storage.py @@ -19,6 +19,7 @@ from __future__ import annotations import contextlib +import hashlib import json from functools import lru_cache from typing import Any @@ -54,8 +55,9 @@ class DurableStorage: Stores step-level caches in a single JSON file on ObjectStorage. All step caches (model responses and tool results) are stored as entries - in a single JSON blob, written to a file named after the task execution: - ``{base_path}/{dag_id}_{task_id}_{run_id}[_{map_index}].json``. + in a single JSON blob, written to ``{base_path}/{cache_id}.json`` where + ``cache_id`` is a hash of the task instance's identity (dag, task, run, + map index) so distinct task instances never share a file. The file survives Airflow task retries since it lives outside the XCom system. It is deleted on successful task completion. @@ -74,8 +76,14 @@ def __init__( run_id: str, map_index: int = -1, ) -> None: - suffix = f"_{map_index}" if map_index >= 0 else "" - self._cache_id = f"{dag_id}_{task_id}_{run_id}{suffix}" + # Hash the identity components with a separator that cannot appear in + # them, so distinct task instances can never alias to the same cache + # file. A plain ``_``-joined string collides -- e.g. dag ``etl`` + task + # ``load_data`` and dag ``etl_load`` + task ``data`` both yield + # ``etl_load_data`` -- letting one task read, overwrite, or delete + # another's durable cache. + identity = "\x00".join([dag_id, task_id, run_id, str(map_index)]) + self._cache_id = hashlib.sha256(identity.encode()).hexdigest() self._cache: dict[str, Any] | None = None def _get_path(self): diff --git a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py index 5d61588f4d075..f83a58f0e2aed 100644 --- a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py +++ b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py @@ -20,6 +20,7 @@ import json from collections.abc import Sequence +from dataclasses import replace from datetime import timedelta from functools import cached_property from typing import TYPE_CHECKING, Any, ClassVar @@ -157,6 +158,14 @@ class AgentOperator(BaseOperator, HITLReviewMixin): On Airflow >= 3.3 the cache is kept in the AIP-103 task state store, so no extra configuration is needed. On older cores it is persisted to ObjectStorage and requires ``[common.ai] durable_cache_path`` to be set. + Tools are durably cached when provided via ``toolsets=`` or via a + concrete pydantic-ai ``Toolset`` capability. Tools reaching the agent + through any *other* capability -- ``MCP``, ``PrefixTools``, + ``CombinedCapability``, a ``Toolset`` backed by a callable factory, or + capabilities loaded from a ``spec_file`` -- are not cached and re-run on + retry; put tools you need replayed in ``toolsets=``. Provider-native + capabilities such as ``WebSearch`` and ``Thinking`` execute inside the + model call and are covered by model-response caching. :param code_mode: When ``True``, wraps the agent's tools in a single ``run_code`` tool powered by the Monty sandbox (pydantic-ai-harness ``CodeMode``). Instead of one model round-trip per tool call, the model @@ -266,6 +275,12 @@ def __init__( self.durable = durable self.code_mode = code_mode + # Populated per run in ``execute`` when durable=True. Declared here so + # ``_build_agent`` -- also reached via ``regenerate_with_feedback`` + # outside ``execute`` -- can read them unconditionally. + self._durable_storage: DurableStorageProtocol | None = None + self._durable_counter: DurableStepCounter | None = None + if durable and enable_hitl_review: raise ValueError("durable=True and enable_hitl_review=True cannot be used together.") @@ -306,18 +321,24 @@ def llm_hook(self) -> PydanticAIHook: def _build_agent(self) -> Agent[object, Any]: """Build and return a pydantic-ai Agent from the operator's config.""" extra_kwargs = dict(self.agent_params) + storage = self._durable_storage + counter = self._durable_counter if self.toolsets: toolsets = self.toolsets - if self.durable and self._durable_storage is not None and self._durable_counter is not None: - toolsets = self._build_durable_toolsets( - toolsets, self._durable_storage, self._durable_counter - ) + if self.durable and storage is not None and counter is not None: + toolsets = self._build_durable_toolsets(toolsets, storage, counter) if self.enable_tool_logging: toolsets = wrap_toolsets_for_logging(toolsets, self.log) extra_kwargs["toolsets"] = toolsets + capabilities = list(extra_kwargs.get("capabilities") or []) + if self.durable and storage is not None and counter is not None: + # Tools supplied through a ``Toolset`` capability bypass the + # ``toolsets=`` wrapping above, so their results would re-execute on + # every retry instead of replaying; wrap their inner toolset too. + capabilities = self._build_durable_capabilities(capabilities, storage, counter) if self.code_mode: - capabilities = list(extra_kwargs.get("capabilities") or []) capabilities.append(_build_code_mode()) + if capabilities: extra_kwargs["capabilities"] = capabilities return self.llm_hook.create_agent( output_type=self.output_type, @@ -333,6 +354,49 @@ def _build_durable_toolsets( return [CachingToolset(wrapped=ts, storage=storage, counter=counter) for ts in toolsets] + def _build_durable_capabilities( + self, capabilities: list[Any], storage: DurableStorageProtocol, counter: DurableStepCounter + ) -> list[Any]: + """ + Wrap toolsets provided via a pydantic-ai ``Toolset`` capability for durable replay. + + Tools reaching the agent through ``capabilities=[Toolset(ts)]`` bypass the + operator's ``toolsets=`` list, so the ``CachingToolset`` applied in + :meth:`_build_durable_toolsets` never sees them and their results + re-execute on every retry instead of replaying. Wrap each ``Toolset`` + capability's inner toolset with the same ``CachingToolset``, preserving + the capability's other fields. Non-``Toolset`` capabilities pass through + unchanged, as does a ``Toolset`` holding a callable factory rather than a + concrete toolset (only a concrete toolset can be wrapped here). + """ + # pydantic-ai (and the pydantic-ai-importing CachingToolset) are imported + # lazily to keep them out of DAG-parse-time imports, matching + # ``_build_durable_toolsets`` and the rest of this module. + from pydantic_ai.capabilities import Toolset + from pydantic_ai.toolsets.abstract import AbstractToolset + + from airflow.providers.common.ai.durable.caching_toolset import CachingToolset + + rewrapped: list[Any] = [] + for capability in capabilities: + # ``Toolset.toolset`` can be a concrete toolset or a callable factory + # resolved per run; only a concrete toolset can be wrapped here. + if isinstance(capability, Toolset) and isinstance(capability.toolset, AbstractToolset): + cached = CachingToolset(wrapped=capability.toolset, storage=storage, counter=counter) + rewrapped.append(replace(capability, toolset=cached)) + continue + if isinstance(capability, Toolset): + # The toolset is a callable factory resolved per run, so there is + # no concrete toolset to wrap; its results won't be cached for + # replay. Warn so durable users aren't silently surprised on retry. + self.log.warning( + "durable=True: tools from a Toolset capability backed by a callable " + "factory are not cached for replay; pass the toolset via `toolsets=` " + "for durability." + ) + rewrapped.append(capability) + return rewrapped + def _build_durable_storage(self, context: Context) -> DurableStorageProtocol: """ Return the durable storage backend for the current task instance. @@ -418,9 +482,6 @@ def execute(self, context: Context) -> Any: c.cached_tool, ) - if self._durable_storage is not None: - self._durable_storage.cleanup() - if self.message_history is not None: self._emit_message_history(context, result) @@ -445,6 +506,14 @@ def execute(self, context: Context) -> Any: if self._serialize_model_output and isinstance(output, BaseModel): output = output.model_dump() + + # Clean up the durable cache only after the run and every post-run step + # that can still fail (the message-history XCom push above and output + # serialization) has succeeded. Cleaning up earlier and then raising + # would leave the Airflow retry with an empty cache, re-executing every + # already-completed model and tool step. + if self._durable_storage is not None: + self._durable_storage.cleanup() return output def _resolve_message_history(self) -> list[ModelMessage] | None: diff --git a/providers/common/ai/tests/unit/common/ai/durable/test_storage.py b/providers/common/ai/tests/unit/common/ai/durable/test_storage.py index 03f85be30e11f..d4faf58024402 100644 --- a/providers/common/ai/tests/unit/common/ai/durable/test_storage.py +++ b/providers/common/ai/tests/unit/common/ai/durable/test_storage.py @@ -50,16 +50,25 @@ def sample_response(): class TestDurableStorageInit: - def test_cache_id_format(self, storage): - assert storage._cache_id == "test_dag_my_task_run_1" - - def test_cache_id_with_map_index(self): - s = DurableStorage(dag_id="d", task_id="t", run_id="r", map_index=3) - assert s._cache_id == "d_t_r_3" - - def test_cache_id_without_map_index(self): - s = DurableStorage(dag_id="d", task_id="t", run_id="r", map_index=-1) - assert "_-1" not in s._cache_id + def test_cache_id_is_deterministic(self): + """The same task identity always maps to the same cache file (so retries resume).""" + a = DurableStorage(dag_id="d", task_id="t", run_id="r", map_index=-1) + b = DurableStorage(dag_id="d", task_id="t", run_id="r", map_index=-1) + assert a._cache_id == b._cache_id + + def test_cache_id_differs_by_map_index(self): + base = DurableStorage(dag_id="d", task_id="t", run_id="r", map_index=-1) + mapped = DurableStorage(dag_id="d", task_id="t", run_id="r", map_index=3) + assert base._cache_id != mapped._cache_id + + def test_cache_id_no_collision_across_tasks(self): + """Distinct (dag, task) pairs that concatenate to the same string must not + share a cache file -- e.g. dag ``etl`` + task ``load_data`` vs dag + ``etl_load`` + task ``data``. A plain ``_``-join aliased them, letting one + task read, overwrite, or delete another task's durable cache.""" + a = DurableStorage(dag_id="etl", task_id="load_data", run_id="r") + b = DurableStorage(dag_id="etl_load", task_id="data", run_id="r") + assert a._cache_id != b._cache_id class TestSaveLoadModelResponse: diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_agent.py b/providers/common/ai/tests/unit/common/ai/operators/test_agent.py index 67c28ebfafe4f..2631c544138f5 100644 --- a/providers/common/ai/tests/unit/common/ai/operators/test_agent.py +++ b/providers/common/ai/tests/unit/common/ai/operators/test_agent.py @@ -22,15 +22,24 @@ import pytest from pydantic import BaseModel +from pydantic_ai import Agent +from pydantic_ai.capabilities import Toolset from pydantic_ai.messages import ( ModelMessagesTypeAdapter, ModelRequest, ModelResponse, TextPart, + ToolCallPart, + ToolReturnPart, UserPromptPart, ) +from pydantic_ai.models.function import FunctionModel +from pydantic_ai.toolsets.function import FunctionToolset from pydantic_ai.usage import UsageLimits +from airflow.providers.common.ai.durable.base import DurableStorageProtocol +from airflow.providers.common.ai.durable.caching_toolset import CachingToolset +from airflow.providers.common.ai.durable.step_counter import DurableStepCounter from airflow.providers.common.ai.durable.storage import DurableStorage from airflow.providers.common.ai.operators.agent import AgentOperator, HITLReviewLink, _build_code_mode from airflow.providers.common.ai.toolsets.logging import LoggingToolset @@ -71,6 +80,33 @@ def _make_mock_agent(output): return mock_agent +class _InMemoryDurableStorage: + """In-memory DurableStorageProtocol backend for exercising real replay in tests.""" + + def __init__(self): + self.models: dict = {} + self.tools: dict = {} + + def save_model_response(self, key, response, *, fingerprint): + self.models[key] = (response, fingerprint) + + def load_model_response(self, key): + return self.models.get(key, (None, None)) + + def save_tool_result(self, key, result, *, fingerprint): + self.tools[key] = (result, fingerprint) + + def load_tool_result(self, key): + if key in self.tools: + value, fingerprint = self.tools[key] + return True, value, fingerprint + return False, None, None + + def cleanup(self): + self.models.clear() + self.tools.clear() + + class TestAgentOperatorValidation: def test_requires_llm_conn_id(self): with pytest.raises(TypeError): @@ -584,7 +620,10 @@ def test_build_durable_storage_falls_back_to_object_storage_below_3_3(self): storage = op._build_durable_storage({"task_instance": ti}) assert isinstance(storage, DurableStorage) - assert storage._cache_id == "d_t_r" + # cache_id is a stable hash of the identity components, not a raw concat. + assert ( + storage._cache_id == DurableStorage(dag_id="d", task_id="t", run_id="r", map_index=-1)._cache_id + ) @patch("pydantic_ai.models.wrapper.infer_model", side_effect=lambda m: m) @patch("pydantic_ai.models.infer_model", autospec=True) @@ -629,6 +668,97 @@ def test_execute_non_durable_does_not_wrap(self, mock_hook_cls): # run_sync called directly, no override mock_agent.run_sync.assert_called_once_with("test", usage_limits=None) + def test_build_durable_capabilities_wraps_toolset_capability(self): + """A ``Toolset`` capability's inner toolset is wrapped with CachingToolset; + capabilities that are not ``Toolset`` pass through unchanged.""" + inner = FunctionToolset() + passthrough = object() + op = AgentOperator(task_id="t", prompt="p", llm_conn_id="c", durable=True) + + result = op._build_durable_capabilities( + [Toolset(inner), passthrough], MagicMock(spec=DurableStorageProtocol), DurableStepCounter() + ) + + assert isinstance(result[0], Toolset) + assert isinstance(result[0].toolset, CachingToolset) + assert result[0].toolset.wrapped is inner + assert result[1] is passthrough + + def test_build_durable_capabilities_skips_callable_toolset_factory(self): + """A ``Toolset`` holding a callable factory (resolved per run with + RunContext) cannot be wrapped with CachingToolset, so it passes through.""" + + def factory(ctx): + return FunctionToolset() + + cap = Toolset(factory) + op = AgentOperator(task_id="t", prompt="p", llm_conn_id="c", durable=True) + + result = op._build_durable_capabilities( + [cap], MagicMock(spec=DurableStorageProtocol), DurableStepCounter() + ) + + assert result[0] is cap + + def test_toolset_capability_tool_replayed_on_retry(self): + """A tool supplied via a ``Toolset`` capability is cached and replayed on a + retry instead of re-executing. Regression: such tools bypassed the + ``CachingToolset`` because they did not arrive via the ``toolsets=`` list.""" + calls = {"n": 0} + + def my_tool() -> str: + calls["n"] += 1 + return "tool-result" + + def model_fn(messages, info): + saw_return = any(isinstance(p, ToolReturnPart) for m in messages for p in getattr(m, "parts", [])) + if saw_return: + return ModelResponse(parts=[TextPart(content="done")]) + return ModelResponse(parts=[ToolCallPart(tool_name="my_tool", args={}, tool_call_id="c1")]) + + # Shared storage across two attempts; the second (a retry) must replay the + # cached tool result rather than executing the tool a second time. + storage = _InMemoryDurableStorage() + for _ in range(2): + op = AgentOperator( + task_id="t", + prompt="hi", + llm_conn_id="c", + durable=True, + enable_tool_logging=False, + agent_params={"capabilities": [Toolset(FunctionToolset(tools=[my_tool]))]}, + ) + op._durable_storage = storage + op._durable_counter = DurableStepCounter() + hook = MagicMock(spec=["create_agent"]) + hook.create_agent.side_effect = lambda **kw: Agent(FunctionModel(model_fn), **kw) + op.llm_hook = hook + op._build_agent().run_sync("hi") + + assert calls["n"] == 1 + + @patch("pydantic_ai.models.wrapper.infer_model", side_effect=lambda m: m) + @patch("pydantic_ai.models.infer_model", autospec=True) + @patch("airflow.providers.common.ai.operators.agent.AgentOperator._build_durable_storage") + @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook", autospec=True) + def test_cleanup_skipped_when_post_run_step_fails(self, mock_hook_cls, mock_build_storage, mock_infer, _): + """Durable cleanup must not run if a post-run step (the message-history XCom + push) fails, so the Airflow retry can still replay the cached steps.""" + storage = MagicMock(spec=DurableStorageProtocol) + mock_build_storage.return_value = storage + + mock_agent = MagicMock(spec=["run_sync", "model", "override"]) + mock_agent.run_sync.return_value = _make_mock_run_result("ok") + mock_agent.model = "test-model" + mock_hook_cls.get_hook.return_value.create_agent.return_value = mock_agent + + op = AgentOperator(task_id="t", prompt="p", llm_conn_id="c", durable=True, message_history="[]") + with patch.object(op, "_emit_message_history", side_effect=RuntimeError("xcom down")): + with pytest.raises(RuntimeError, match="xcom down"): + op.execute(context={}) + + storage.cleanup.assert_not_called() + @pytest.mark.skipif( not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible with Airflow >= 3.1.0" From 174803c42d19a5c707df29105da79475a944b1d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ortega?= Date: Fri, 17 Jul 2026 22:02:36 +0900 Subject: [PATCH 04/86] Don't deactivate DAG bundles owned by other dag-processors (#69964) `DagBundlesManager.sync_bundles_to_db()` marks every stored bundle that is not in the calling process's config as inactive. This assumes the caller sees the complete bundle configuration. When multiple dag-processors are each configured with a partial config (one bundle per processor, a natural way to shard parsing), each processor treats the other processors' bundles as "no longer found in config" and disables them. Processor A disables B's bundle, B disables A's, and they flip `dag_bundle.active` on every parse cycle -- the deployment never converges and continuously logs "DAG bundle ... is no longer found in config and has been disabled" for bundles that are actively configured elsewhere. Add a `deactivate_missing` flag (default `True`, preserving existing single-processor behavior) and have `DagFileProcessorManager.sync_bundles()` pass `deactivate_missing=False` when the processor was started with a bundle filter (`--bundle-name` / `bundle_names_to_parse`), i.e. when it only owns a subset of the bundles. closes: #69963 related: #69698 Generated-by: Claude Code following the guidelines --- .../airflow/dag_processing/bundles/manager.py | 15 ++++- .../src/airflow/dag_processing/manager.py | 4 +- .../bundles/test_dag_bundle_manager.py | 56 +++++++++++++++++++ .../tests/unit/dag_processing/test_manager.py | 14 +++++ 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/airflow-core/src/airflow/dag_processing/bundles/manager.py b/airflow-core/src/airflow/dag_processing/bundles/manager.py index 7d966e0b5074f..66fa318316d89 100644 --- a/airflow-core/src/airflow/dag_processing/bundles/manager.py +++ b/airflow-core/src/airflow/dag_processing/bundles/manager.py @@ -271,7 +271,7 @@ def parse_config(self) -> None: self.log.info("DAG bundles loaded: %s", ", ".join(self._bundle_config.keys())) @provide_session - def sync_bundles_to_db(self, *, session: Session = NEW_SESSION) -> None: + def sync_bundles_to_db(self, *, deactivate_missing: bool = True, session: Session = NEW_SESSION) -> None: """ Persist the configured DAG bundles into ``DagBundleModel`` rows. @@ -280,6 +280,14 @@ def sync_bundles_to_db(self, *, session: Session = NEW_SESSION) -> None: ``DagModel`` / ``SerializedDagModel`` rows is the responsibility of ``DagBag`` plus ``sync_bag_to_db`` (or, in production, the DAG processor); calling this method does not trigger that work. + + :param deactivate_missing: When ``True`` (the default), any bundle stored in + the database that is not present in this manager's config is marked + inactive. This is only correct when the calling process sees the + *complete* bundle configuration. When multiple dag-processors each run + with a partial config (e.g. one bundle per processor), pass ``False`` so a + processor does not disable bundles owned by other processors -- otherwise + the processors repeatedly deactivate each other and never converge. """ self.log.debug("Syncing DAG bundles to the database") @@ -356,6 +364,11 @@ def _extract_and_sign_template(bundle_name: str) -> tuple[str | None, dict]: ) bundle.teams = [] + if not deactivate_missing: + # This process only owns a subset of the bundles, so the remaining stored + # bundles may well be owned by another dag-processor. Leave them alone. + return + # Import here to avoid circular import from airflow.models.errors import ParseImportError diff --git a/airflow-core/src/airflow/dag_processing/manager.py b/airflow-core/src/airflow/dag_processing/manager.py index cd6e48b542c3e..3f60cb3f4c4de 100644 --- a/airflow-core/src/airflow/dag_processing/manager.py +++ b/airflow-core/src/airflow/dag_processing/manager.py @@ -333,7 +333,9 @@ def _exit_gracefully(self, signum, frame): def sync_bundles(self) -> None: """Sync configured DAG bundles to the metadata database.""" - DagBundlesManager().sync_bundles_to_db() + # When this processor only parses a subset of bundles, it does not see the full + # bundle configuration and must not deactivate bundles owned by other processors. + DagBundlesManager().sync_bundles_to_db(deactivate_missing=not self.bundle_names_to_parse) def get_all_bundles(self) -> list[BaseDagBundle]: """Return configured DAG bundles filtered by ``bundle_names_to_parse`` if provided.""" diff --git a/airflow-core/tests/unit/dag_processing/bundles/test_dag_bundle_manager.py b/airflow-core/tests/unit/dag_processing/bundles/test_dag_bundle_manager.py index 0d17069c831dd..4b3fc1078abb1 100644 --- a/airflow-core/tests/unit/dag_processing/bundles/test_dag_bundle_manager.py +++ b/airflow-core/tests/unit/dag_processing/bundles/test_dag_bundle_manager.py @@ -119,6 +119,14 @@ def path(self): } ] +OTHER_BUNDLE_CONFIG = [ + { + "name": "other-test-bundle", + "classpath": "unit.dag_processing.bundles.test_dag_bundle_manager.BasicBundle", + "kwargs": {"refresh_interval": 1}, + } +] + def test_get_bundle(): """Test that get_bundle builds and returns a bundle.""" @@ -217,6 +225,54 @@ def test_sync_bundles_to_db_does_not_log_removing_none_team(clear_db, caplog): assert "Removing ownership of team 'None'" not in caplog.text +@pytest.mark.db_test +@conf_vars({("core", "LOAD_EXAMPLES"): "False"}) +def test_sync_bundles_to_db_partial_config_does_not_disable_other_bundles(clear_db, session): + """ + Multiple dag-processors, each configured with only its own bundle, must not + disable each other's bundles. + + Each processor is started with ``--bundle-name`` (``bundle_names_to_parse``) + and a ``dag_bundle_config_list`` containing only the bundle it owns. When + ``sync_bundles_to_db`` is told it only owns a subset (``deactivate_missing=False``), + it must leave bundles it does not know about untouched instead of marking them + inactive. Otherwise processor A disables B's bundle, B disables A's, and neither + ever makes progress. + """ + + def _get_bundle_names_and_active(): + return session.execute( + select(DagBundleModel.name, DagBundleModel.active).order_by(DagBundleModel.name) + ).all() + + # Processor A: only knows "my-test-bundle". + with patch.dict( + os.environ, {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST": json.dumps(BASIC_BUNDLE_CONFIG)} + ): + DagBundlesManager().sync_bundles_to_db(deactivate_missing=False) + assert _get_bundle_names_and_active() == [("my-test-bundle", True)] + + # Processor B: only knows "other-test-bundle". It must not touch A's bundle. + with patch.dict( + os.environ, {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST": json.dumps(OTHER_BUNDLE_CONFIG)} + ): + DagBundlesManager().sync_bundles_to_db(deactivate_missing=False) + assert _get_bundle_names_and_active() == [ + ("my-test-bundle", True), + ("other-test-bundle", True), + ] + + # Processor A runs again: still must not disable B's bundle. + with patch.dict( + os.environ, {"AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST": json.dumps(BASIC_BUNDLE_CONFIG)} + ): + DagBundlesManager().sync_bundles_to_db(deactivate_missing=False) + assert _get_bundle_names_and_active() == [ + ("my-test-bundle", True), + ("other-test-bundle", True), + ] + + @conf_vars({("dag_processor", "dag_bundle_config_list"): json.dumps(BASIC_BUNDLE_CONFIG)}) @pytest.mark.parametrize("version", [None, "hello"]) def test_view_url(version): diff --git a/airflow-core/tests/unit/dag_processing/test_manager.py b/airflow-core/tests/unit/dag_processing/test_manager.py index ae28f3317fb79..74da895bca30a 100644 --- a/airflow-core/tests/unit/dag_processing/test_manager.py +++ b/airflow-core/tests/unit/dag_processing/test_manager.py @@ -295,6 +295,20 @@ def test_get_observed_filelocs_expands_zip_inner_paths(self, tmp_path): "test_zip.zip/broken_dag.py", } + def test_sync_bundles_deactivates_missing_when_owning_all_bundles(self): + """A processor with no bundle filter owns the full config and may deactivate missing bundles.""" + manager = DagFileProcessorManager(max_runs=1) + with mock.patch("airflow.dag_processing.manager.DagBundlesManager") as mock_bundles_manager: + manager.sync_bundles() + mock_bundles_manager.return_value.sync_bundles_to_db.assert_called_once_with(deactivate_missing=True) + + def test_sync_bundles_does_not_deactivate_missing_when_filtered(self): + """A processor started with ``--bundle-name`` owns a subset and must not deactivate others.""" + manager = DagFileProcessorManager(max_runs=1, bundle_names_to_parse=["only-mine"]) + with mock.patch("airflow.dag_processing.manager.DagBundlesManager") as mock_bundles_manager: + manager.sync_bundles() + mock_bundles_manager.return_value.sync_bundles_to_db.assert_called_once_with(deactivate_missing=False) + @pytest.mark.usefixtures("clear_parse_import_errors") def test_refresh_dag_bundles_keeps_zip_inner_file_errors(self, session, tmp_path, configure_dag_bundles): bundle_path = tmp_path / "bundleone" From 467242823a68056174ea9aabd865f74d16bfc07d Mon Sep 17 00:00:00 2001 From: Pierre Jeambrun Date: Fri, 17 Jul 2026 15:26:47 +0200 Subject: [PATCH 05/86] Authorize Dag reparse against the file's Dags, not the query-string dag_id (#69471) * Authorize Dag reparse against the file's Dags, not the query-string dag_id The PUT /parseDagFile/{file_token} endpoint authorized the caller through a route dependency that, with no dag_id path parameter, resolves the target from the query string, which is decoupled from the Dag file the signed file_token actually resolves to. Add a requires_access_dag_from_file_token dependency that decodes the token, resolves the Dags defined in that file, and authorizes the caller against exactly those Dags, so authorization always matches the Dag being reparsed regardless of any request parameter. * Update airflow-core/src/airflow/api_fastapi/core_api/security.py Co-authored-by: Amogh Desai * Fix CI --------- Co-authored-by: Amogh Desai --- .../core_api/routes/public/dag_parsing.py | 49 ++++--------------- .../airflow/api_fastapi/core_api/security.py | 44 +++++++++++++++++ .../routes/public/test_dag_parsing.py | 11 ++++- scripts/ci/prek/extract_permissions.py | 1 + 4 files changed, 63 insertions(+), 42 deletions(-) diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_parsing.py b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_parsing.py index 5a9a5d7e397aa..1a901537dce0e 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_parsing.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/dag_parsing.py @@ -16,25 +16,16 @@ # under the License. from __future__ import annotations -from collections.abc import Sequence -from typing import TYPE_CHECKING +from fastapi import Depends, Request, status +from itsdangerous import URLSafeSerializer -from fastapi import Depends, HTTPException, Request, status -from itsdangerous import BadSignature, URLSafeSerializer -from sqlalchemy import select - -from airflow.api_fastapi.auth.managers.models.resource_details import DagDetails from airflow.api_fastapi.common.db.common import SessionDep from airflow.api_fastapi.common.router import AirflowRouter from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc -from airflow.api_fastapi.core_api.security import requires_access_dag +from airflow.api_fastapi.core_api.security import requires_access_dag_from_file_token from airflow.api_fastapi.logging.decorators import action_logging -from airflow.models.dag import DagModel from airflow.models.dagbag import DagPriorityParsingRequest -if TYPE_CHECKING: - from airflow.api_fastapi.auth.managers.models.batch_apis import IsAuthorizedDagRequest - dag_parsing_router = AirflowRouter(tags=["DAG Parsing"], prefix="/parseDagFile/{file_token}") @@ -42,34 +33,12 @@ "", responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]), status_code=status.HTTP_201_CREATED, - dependencies=[Depends(requires_access_dag(method="PUT")), Depends(action_logging())], + dependencies=[Depends(requires_access_dag_from_file_token(method="PUT")), Depends(action_logging())], ) -def reparse_dag_file( - file_token: str, - session: SessionDep, - request: Request, -) -> None: +def reparse_dag_file(file_token: str, session: SessionDep, request: Request) -> None: """Request re-parsing a Dag file.""" - secret_key = request.app.state.secret_key - auth_s = URLSafeSerializer(secret_key) - try: - payload = auth_s.loads(file_token) - except BadSignature: - raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found") - - bundle_name = payload["bundle_name"] - relative_fileloc = payload["relative_fileloc"] - - requests: Sequence[IsAuthorizedDagRequest] = [ - {"method": "PUT", "details": DagDetails(id=dag_id)} - for dag_id in session.scalars( - select(DagModel.dag_id).where( - DagModel.bundle_name == bundle_name, DagModel.relative_fileloc == relative_fileloc - ) - ) - ] - if not requests: - raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found") - - parsing_request = DagPriorityParsingRequest(bundle_name=bundle_name, relative_fileloc=relative_fileloc) + payload = URLSafeSerializer(request.app.state.secret_key).loads(file_token) + parsing_request = DagPriorityParsingRequest( + bundle_name=payload["bundle_name"], relative_fileloc=payload["relative_fileloc"] + ) session.add(parsing_request) diff --git a/airflow-core/src/airflow/api_fastapi/core_api/security.py b/airflow-core/src/airflow/api_fastapi/core_api/security.py index 724a6967f7499..5f85b68e6b142 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/security.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/security.py @@ -25,6 +25,7 @@ from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordBearer +from itsdangerous import BadSignature, URLSafeSerializer from jwt import ExpiredSignatureError, InvalidTokenError from sqlalchemy import or_, select from sqlalchemy.orm import Session @@ -198,6 +199,49 @@ def inner( return inner +def requires_access_dag_from_file_token( + method: ResourceMethod, +) -> Callable[[str, Request, BaseUser, Session], None]: + """ + Authorize the caller against the DAGs referenced by a signed ``file_token``. + + For ``file_token`` based endpoints (such as ``reparse``), the token is resolved to its referenced file, and authorization is performed against exactly the DAGs defined in that file, never against a request parameter. + """ + + def inner( + file_token: str, + request: Request, + user: GetUserDep, + session: SessionDep, + ) -> None: + try: + payload = URLSafeSerializer(request.app.state.secret_key).loads(file_token) + except BadSignature: + raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found") + + dag_ids = list( + session.scalars( + select(DagModel.dag_id).where( + DagModel.bundle_name == payload["bundle_name"], + DagModel.relative_fileloc == payload["relative_fileloc"], + ) + ) + ) + if not dag_ids: + raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found") + + dag_id_to_team = DagModel.get_dag_id_to_team_name_mapping(dag_ids, session=session) + requests: list[IsAuthorizedDagRequest] = [ + {"method": method, "details": DagDetails(id=dag_id, team_name=dag_id_to_team.get(dag_id))} + for dag_id in dag_ids + ] + _requires_access( + is_authorized_callback=lambda: get_auth_manager().batch_is_authorized_dag(requests, user=user), + ) + + return inner + + class PermittedDagFilter(OrmClause[set[str]]): """A parameter that filters the permitted dags for the user.""" diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_parsing.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_parsing.py index 763e5e88669e2..b60b7011a9ea3 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_parsing.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dag_parsing.py @@ -75,8 +75,15 @@ def test_should_respond_401(self, unauthenticated_test_client): ) assert response.status_code == 401 - def test_should_respond_403(self, unauthorized_test_client): - response = unauthorized_test_client.put("/parseDagFile/token", headers={"Accept": "application/json"}) + def test_should_respond_403(self, unauthorized_test_client, url_safe_serializer, session): + parse_and_sync_to_db(EXAMPLE_DAG_FILE) + test_dag = DBDagBag(load_op_links=False).get_latest_version_of_dag(TEST_DAG_ID, session=session) + token = url_safe_serializer.dumps( + {"bundle_name": "example_dags", "relative_fileloc": test_dag.relative_fileloc} + ) + response = unauthorized_test_client.put( + f"/parseDagFile/{token}", headers={"Accept": "application/json"} + ) assert response.status_code == 403 def test_bad_file_request(self, url_safe_serializer, session, test_client): diff --git a/scripts/ci/prek/extract_permissions.py b/scripts/ci/prek/extract_permissions.py index e9e454fe29907..1f0c68d313eb7 100644 --- a/scripts/ci/prek/extract_permissions.py +++ b/scripts/ci/prek/extract_permissions.py @@ -225,6 +225,7 @@ def _extract_entity_arg(call_node: ast.Call) -> str | None: # Map from requires_access_* function name → (resource base name, forced entity or None) _FN_TO_RESOURCE_INFO: dict[str, tuple[str, str | None]] = { "requires_access_dag": ("DAG", None), + "requires_access_dag_from_file_token": ("DAG", None), # reparse authorizes the file_token's Dags "requires_access_backfill": ("DAG", "RUN"), # backfill is a DAG.RUN alias "requires_access_dag_run_bulk": ("DAG", "RUN"), # dag_run bulk is a DAG.RUN alias "requires_access_dag_run_clear_bulk": ("DAG", "RUN"), # dag_run clear bulk is a DAG.RUN alias From 61f6925a837845237a235ec00f0aa76b134ab608 Mon Sep 17 00:00:00 2001 From: Pierre Jeambrun Date: Fri, 17 Jul 2026 16:30:58 +0200 Subject: [PATCH 06/86] Restore graph task and group coloring via Chakra palette tokens (#68760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Restore graph task and group coloring via Chakra palette tokens Custom operator and TaskGroup colors disappeared in Airflow 3: the ui_color/ui_fgcolor attributes were kept but the new UI ignored them, so teams that relied on color to parse large graphs at a glance lost that signal (see the demand on the linked issue). Bring the coloring back in a way that fits the new opinionated theming: the value must be a Chakra palette token (e.g. blue.500), which resolves through a theme-controlled CSS variable and therefore stays legible in both light and dark mode and under custom themes -- the dark-mode and accessibility concerns that drove the original removal. The graph paints the node fill from ui_color and the operator label from ui_fgcolor, as in Airflow 2.x, while the border keeps showing run state. Legacy hex/named values can no longer adapt to the theme, so they are ignored by the graph and emit a warning for user-authored operators and task groups. * Color task groups in the graph view, including when expanded ui_color and ui_fgcolor are documented as the fill and label colors of a TaskGroup node, and Airflow 2.x painted them, but the graph excluded groups from the custom fill and dropped the colors entirely once a group was expanded. Treat groups like leaf tasks so a token-valued group color renders its fill and label in both the collapsed and expanded states. * Accept raw hex colors and Chakra tokens for graph node coloring Review feedback asked to keep the Airflow 2 behavior where ui_color and ui_fgcolor accept a raw color (a hex code or CSS name) in addition to Chakra palette tokens, so existing Dags keep their graph colors without any change. Dark-mode color variants are deferred to a follow-up. * Adjustments * Fix unreadable graph task text on light custom colors in dark mode The task icon and label fell back to the theme's default foreground for Chakra token fills, so a light token such as yellow.200 rendered light text on a light fill in dark mode. Deriving the text color from the resolved fill measures tokens too, so contrast holds for hex, CSS names, and tokens alike. * Update task group graph test fixture for restored node colors The graph serializer now emits each node's ui_color/ui_fgcolor for task and group nodes, so the expected graph fixture must include them; join nodes stay uncolored. The fixture was stale and omitted these fields, failing test_task_group_to_dict_serialized_dag and test_task_group_to_dict_alternative_syntax. * Document semantic theme tokens for ui_color and ui_fgcolor The graph node color attributes accept theme-aware semantic tokens (e.g. brand.solid) that adapt to light and dark mode, in addition to palette tokens and raw colors — but the docs only mentioned palette tokens, so users had no pointer to the semantic tokens available in the UI theme. --- airflow-core/docs/howto/custom-operator.rst | 14 ++- .../newsfragments/68760.significant.rst | 11 ++ .../core_api/datamodels/ui/structure.py | 2 + .../core_api/openapi/_private_ui.yaml | 10 ++ .../core_api/services/ui/task_group.py | 9 ++ .../ui/openapi-gen/requests/schemas.gen.ts | 22 ++++ .../ui/openapi-gen/requests/types.gen.ts | 2 + .../ui/src/components/Graph/TaskNode.test.tsx | 100 ++++++++++++++++++ .../ui/src/components/Graph/TaskNode.tsx | 37 ++++++- .../components/Graph/elkGraphUtils.test.ts | 56 ++++++++++ .../ui/src/components/Graph/elkGraphUtils.ts | 6 ++ .../ui/src/components/Graph/nodeColors.ts | 85 +++++++++++++++ .../ui/src/components/Graph/reactflowUtils.ts | 2 + .../core_api/routes/ui/test_structure.py | 79 ++++++++++++++ .../tests/unit/utils/test_task_group.py | 13 ++- .../src/airflow/sdk/definitions/taskgroup.py | 16 ++- 16 files changed, 449 insertions(+), 15 deletions(-) create mode 100644 airflow-core/newsfragments/68760.significant.rst create mode 100644 airflow-core/src/airflow/ui/src/components/Graph/TaskNode.test.tsx create mode 100644 airflow-core/src/airflow/ui/src/components/Graph/nodeColors.ts diff --git a/airflow-core/docs/howto/custom-operator.rst b/airflow-core/docs/howto/custom-operator.rst index f250aa7447beb..858b1154c412e 100644 --- a/airflow-core/docs/howto/custom-operator.rst +++ b/airflow-core/docs/howto/custom-operator.rst @@ -132,16 +132,20 @@ The ``execute`` gets called only during a Dag run. User interface -------------- -Airflow also allows the developer to control how the operator shows up in the Dag UI. -Override ``ui_color`` to change the background color of the operator in UI. -Override ``ui_fgcolor`` to change the color of the label. +Airflow also allows the developer to control how the operator shows up in the Dag graph view. +Override ``ui_color`` to change the node's fill color and ``ui_fgcolor`` to change its label color. +Each accepts a raw color (a hex code or a CSS color name) or a `Chakra `__ +theme token: a palette token such as ``blue.500``, or a **semantic token** such as ``brand.solid`` +that is defined in the UI theme and adapts to light and dark mode. Theme tokens -- including any +added through a custom UI theme -- resolve through a theme-controlled CSS variable, so they stay +legible across color modes. Override ``custom_operator_name`` to change the displayed name to something other than the classname. .. code-block:: python class HelloOperator(BaseOperator): - ui_color = "#ff0000" - ui_fgcolor = "#000000" + ui_color = "blue.500" # a Chakra palette token + ui_fgcolor = "brand.contrast" # a semantic token that adapts to light and dark mode custom_operator_name = "Howdy" # ... diff --git a/airflow-core/newsfragments/68760.significant.rst b/airflow-core/newsfragments/68760.significant.rst new file mode 100644 index 0000000000000..5b518af99938f --- /dev/null +++ b/airflow-core/newsfragments/68760.significant.rst @@ -0,0 +1,11 @@ +Custom graph colors return via ``ui_color`` and ``ui_fgcolor`` + +The ``ui_color`` and ``ui_fgcolor`` attributes on operators and TaskGroups color the graph view +again -- the node fill from ``ui_color`` and the operator label from ``ui_fgcolor``, as they did in +Airflow 2. They were silently ignored since Airflow 3.0. + +The value can be a raw color (a hex code like ``#e8b7e4`` or a CSS name) or a Chakra theme token -- +a palette token such as ``blue.500`` or a semantic token such as ``brand.solid`` that adapts to +light and dark mode (including tokens added through custom UI theming); a token resolves through a +theme-controlled CSS variable. The node border keeps reflecting the task-instance run state; the +custom color is applied to the fill and label only. diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py index 57f052dbc42a1..5d89b08ee98b6 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/structure.py @@ -42,6 +42,8 @@ class NodeResponse(BaseNodeResponse): setup_teardown_type: Literal["setup", "teardown"] | None = None operator: str | None = None asset_condition_type: Literal["or-gate", "and-gate"] | None = None + ui_color: str | None = None + ui_fgcolor: str | None = None class StructureDataResponse(BaseGraphResponse[EdgeResponse, NodeResponse]): diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml index bcfe8c708ef15..00eaa55a5aa87 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml +++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml @@ -3664,6 +3664,16 @@ components: - and-gate - type: 'null' title: Asset Condition Type + ui_color: + anyOf: + - type: string + - type: 'null' + title: Ui Color + ui_fgcolor: + anyOf: + - type: string + - type: 'null' + title: Ui Fgcolor type: object required: - id diff --git a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py index 47d49757e69e4..139e0844091fe 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py @@ -37,6 +37,13 @@ def get_task_group_children_getter() -> Callable: return methodcaller("hierarchical_alphabetical_sort") +def _ui_colors(node) -> dict[str, str]: + """Return the node's ``ui_color``/``ui_fgcolor`` values (hex or Chakra token) for the graph.""" + return { + key: value for key in ("ui_color", "ui_fgcolor") if (value := getattr(node, key, None)) is not None + } + + def task_group_to_dict(task_item_or_group, parent_group_is_mapped=False): """Create a nested dict representation of this TaskGroup and its children used to construct the Graph.""" if isinstance(task := task_item_or_group, (SerializedBaseOperator, SerializedMappedOperator)): @@ -47,6 +54,7 @@ def task_group_to_dict(task_item_or_group, parent_group_is_mapped=False): "label": task_display_name, "operator": task.operator_name, "type": "task", + **_ui_colors(task), } if task.is_setup: node_operator["setup_teardown_type"] = "setup" @@ -78,6 +86,7 @@ def task_group_to_dict(task_item_or_group, parent_group_is_mapped=False): "is_mapped": mapped, "children": children, "type": "task", + **_ui_colors(task_group), } return node diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts index a2fc21293c8fb..6105a80121503 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts @@ -10342,6 +10342,28 @@ export const $NodeResponse = { } ], title: 'Asset Condition Type' + }, + ui_color: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Ui Color' + }, + ui_fgcolor: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Ui Fgcolor' } }, type: 'object', diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts index fa719ec13df09..901e20ccc13da 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts @@ -2623,6 +2623,8 @@ export type NodeResponse = { setup_teardown_type?: 'setup' | 'teardown' | null; operator?: string | null; asset_condition_type?: 'or-gate' | 'and-gate' | null; + ui_color?: string | null; + ui_fgcolor?: string | null; }; export type OklchColor = string; diff --git a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.test.tsx b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.test.tsx new file mode 100644 index 0000000000000..821d0ac2fbe15 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.test.tsx @@ -0,0 +1,100 @@ +/*! + * 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. + */ +import { render } from "@testing-library/react"; +import { ReactFlowProvider } from "@xyflow/react"; +import type { ComponentProps, ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { Wrapper } from "src/utils/Wrapper"; + +import { TaskNode } from "./TaskNode"; +import { readableTextForFill } from "./nodeColors"; +import type { CustomNodeProps } from "./reactflowUtils"; + +vi.mock("src/context/groups", () => ({ + useGroups: vi.fn(() => ({ toggleGroupId: vi.fn() })), +})); + +const TestWrapper = ({ children }: { readonly children: ReactNode }) => ( + + {children} + +); + +// Chakra/Panda hashes color props into atomic class names rather than inline styles, so the resolved +// colour cannot be read back in jsdom. Instead we render two otherwise-identical nodes that differ +// only in the prop under test: any markup difference is attributable to that prop (hashing is +// deterministic), and identical markup proves the prop had no effect. +const renderHtml = (data: Partial): string => { + const { container } = render( + // The xyflow NodeProps surface is large; the component only reads `data` and `id`. + )} + />, + { wrapper: TestWrapper }, + ); + + return container.innerHTML; +}; + +describe("TaskNode operator colors", () => { + it("tints a leaf task when ui_color is set", () => { + expect(renderHtml({ operator: "BashOperator", uiColor: "blue.500" })).not.toBe( + renderHtml({ operator: "BashOperator" }), + ); + }); + + it("colors the operator text when ui_fgcolor is set", () => { + expect(renderHtml({ operator: "BashOperator", uiFgcolor: "red.700" })).not.toBe( + renderHtml({ operator: "BashOperator" }), + ); + }); + + it("tints a leaf task when ui_color is a raw hex color", () => { + expect(renderHtml({ operator: "BashOperator", uiColor: "#e8b7e4" })).not.toBe( + renderHtml({ operator: "BashOperator" }), + ); + }); + + it("tints a group node when ui_color is a token (2.x parity: ui_color is the group fill)", () => { + expect(renderHtml({ isGroup: true, uiColor: "blue.500" })).not.toBe(renderHtml({ isGroup: true })); + }); + + it("alternates the group fill shade by nesting depth so nested groups stay distinct", () => { + expect(renderHtml({ depth: 0, isGroup: true, isOpen: true, uiColor: "blue.500" })).not.toBe( + renderHtml({ depth: 1, isGroup: true, isOpen: true, uiColor: "blue.500" }), + ); + }); +}); + +describe("readableTextForFill", () => { + it.each([ + { color: "#ffffff", expected: "black" }, + { color: "#fff", expected: "black" }, + { color: "#000000", expected: "gray.50" }, + { color: "#e8b7e4", expected: "black" }, + { color: "#1f77b4", expected: "gray.50" }, + { color: "blue.500", expected: undefined }, + { color: undefined, expected: undefined }, + ])("returns $expected for a fill of $color", ({ color, expected }) => { + expect(readableTextForFill(color)).toBe(expected); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx index 0efe7dbbb81a3..e35a4c2a47be9 100644 --- a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx +++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Button, Flex, HStack, LinkOverlay, Text } from "@chakra-ui/react"; +import { Box, Button, Flex, HStack, LinkOverlay, Text, useToken } from "@chakra-ui/react"; import type { NodeProps, Node as NodeType } from "@xyflow/react"; import { useTranslation } from "react-i18next"; import { AiOutlineGroup } from "react-icons/ai"; @@ -30,8 +30,14 @@ import { NodeWrapper } from "./NodeWrapper"; import { SegmentedStateBar } from "./SegmentedStateBar"; import { TaskLink } from "./TaskLink"; import { opacityStyle } from "./graphTypes"; +import { readableTextForFill } from "./nodeColors"; import type { CustomNodeProps } from "./reactflowUtils"; +// A colored group is softened toward the surface and alternates between these two shares of the custom +// color by nesting depth, so open nested groups stay visually distinct (like the bg.muted/bg stripes). +const GROUP_FILL_STRONG = 45; +const GROUP_FILL_SOFT = 22; + export const TaskNode = ({ data: { childCount, @@ -48,12 +54,17 @@ export const TaskNode = ({ taskInstance, team, tooltip, + uiColor, + uiFgcolor, width = 0, }, id, }: NodeProps>) => { const { t: translate } = useTranslation("components"); const { toggleGroupId } = useGroups(); + // Resolve the fill through Chakra so any token (dotted like "blue.500" or bare like "bg") becomes a + // valid CSS color for the group color-mix; raw hex/CSS names pass through unchanged. + const [resolvedFill] = useToken("colors", [uiColor ?? "transparent"]); const onClick = () => { if (isGroup) { toggleGroupId(id); @@ -85,6 +96,24 @@ export const TaskNode = ({ .map(([_state, count]) => count) .reduce((sum, val) => sum + val, 0); + // Custom colors can mess up the readability of the text, so we calculate a readable foreground color for the node based on the background color. + // Pass the resolved color so Chakra tokens are measured by their hex rather than skipped. + const readableFgColor = isGroup ? undefined : readableTextForFill(resolvedFill); + // Alternate nested groups colors so nested groups are visually distinct and readable + const shouldAlternate = isOpen && depth !== undefined && depth % 2 === 0; + + let nodeBg: string; + + if (!isGroup) { + nodeBg = uiColor ?? "bg"; + } else if (uiColor === undefined) { + nodeBg = shouldAlternate ? "bg.muted" : "bg"; + } else { + const share = shouldAlternate ? GROUP_FILL_STRONG : GROUP_FILL_SOFT; + + nodeBg = `color-mix(in srgb, ${resolvedFill} ${share}%, var(--chakra-colors-bg))`; + } + return ( @@ -97,13 +126,13 @@ export const TaskNode = ({ tooltip={isGroup ? tooltip : undefined} > { expect(rootEdgeIds).toEqual(new Set(["start-group_a", "group_a-final_task"])); }); }); + +describe("generateElkGraph — operator colors", () => { + it("maps ui_color/ui_fgcolor onto the formatted leaf node", () => { + const root = generateElkGraph({ + direction: "RIGHT", + edges: [], + font: "12px sans-serif", + nodes: [buildNode({ id: "t1", label: "t1", ui_color: "blue.500", ui_fgcolor: "red.700" })], + openGroupIds: [], + }); + + const node = root.children?.[0] as FormattedNode; + + expect(node.uiColor).toBe("blue.500"); + expect(node.uiFgcolor).toBe("red.700"); + }); + + it("leaves the colors undefined when the node has none", () => { + const root = generateElkGraph({ + direction: "RIGHT", + edges: [], + font: "12px sans-serif", + nodes: [buildNode({ id: "t1", label: "t1" })], + openGroupIds: [], + }); + + const node = root.children?.[0] as FormattedNode; + + expect(node.uiColor).toBeUndefined(); + expect(node.uiFgcolor).toBeUndefined(); + }); + + it("maps ui_color/ui_fgcolor onto an expanded (open) group node", () => { + const root = generateElkGraph({ + direction: "RIGHT", + edges: [], + font: "12px sans-serif", + nodes: [ + buildNode({ + children: [buildNode({ id: "g.t1", label: "t1" })], + id: "g", + label: "g", + ui_color: "purple.600", + ui_fgcolor: "green.600", + }), + ], + openGroupIds: ["g"], + }); + + const group = (root.children as Array).find((child) => child.id === "g"); + + expect(group?.isOpen).toBe(true); + expect(group?.uiColor).toBe("purple.600"); + expect(group?.uiFgcolor).toBe("green.600"); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts index 6e8548fbed17f..40c6875e4991a 100644 --- a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts +++ b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts @@ -49,6 +49,8 @@ export type FormattedNode = { isOpen?: boolean; setupTeardownType?: NodeResponse["setup_teardown_type"]; team?: string | null; + uiColor?: string | null; + uiFgcolor?: string | null; } & ElkShape & NodeResponse; @@ -352,6 +354,8 @@ export const generateElkGraph = ({ "elk.padding": "[top=80,left=15,bottom=15,right=15]", ...(direction === "RIGHT" ? { "elk.portConstraints": "FIXED_SIDE" } : {}), }, + uiColor: node.ui_color, + uiFgcolor: node.ui_fgcolor, }; } @@ -392,6 +396,8 @@ export const generateElkGraph = ({ team: node.team, tooltip: node.tooltip, type: node.type, + uiColor: node.ui_color, + uiFgcolor: node.ui_fgcolor, width, }; }; diff --git a/airflow-core/src/airflow/ui/src/components/Graph/nodeColors.ts b/airflow-core/src/airflow/ui/src/components/Graph/nodeColors.ts new file mode 100644 index 0000000000000..6457c87e99f5f --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/Graph/nodeColors.ts @@ -0,0 +1,85 @@ +/*! + * 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. + */ + +// The theme's default text colors ("fg"): black in light mode, gray.50 in dark mode. We pick between +// them by the fill brightness instead of by the color mode, so custom-colored nodes stay legible. +const DARK_TEXT = "black"; +const LIGHT_TEXT = "gray.50"; +const BRIGHTNESS_THRESHOLD = 128; + +const hexToRgb = (hex: string): [number, number, number] | undefined => { + const isShort = /^#[\da-f]{3}$/iu.test(hex); + const isLong = /^#[\da-f]{6}$/iu.test(hex); + + if (!isShort && !isLong) { + return undefined; + } + + // Expand #abc to #aabbcc so both forms share one slicing path. + const normalized = isShort + ? `#${hex.slice(1, 2).repeat(2)}${hex.slice(2, 3).repeat(2)}${hex.slice(3, 4).repeat(2)}` + : hex; + + return [ + parseInt(normalized.slice(1, 3), 16), + parseInt(normalized.slice(3, 5), 16), + parseInt(normalized.slice(5, 7), 16), + ]; +}; + +// Perceived brightness (ITU-R BT.601, 0-255) of a raw color -- a hex code parsed directly, or a CSS +// name normalized through a canvas. Returns undefined for anything that cannot be resolved. +const perceivedBrightness = (color: string): number | undefined => { + let rgb = hexToRgb(color); + + if (rgb === undefined) { + const context = document.createElement("canvas").getContext("2d"); + + if (context !== null) { + context.fillStyle = "#000000"; + context.fillStyle = color; + rgb = hexToRgb(context.fillStyle); + } + } + + if (rgb === undefined) { + return undefined; + } + + const [red, green, blue] = rgb; + + return (red * 299 + green * 587 + blue * 114) / 1000; +}; + +// Pick the theme's dark or light text color for a node painted with the vivid fill `color`, so the +// icon and label stay legible. Chakra palette tokens ("blue.500") are theme-managed and keep the +// default foreground (undefined), as do colors that cannot be parsed. +export const readableTextForFill = (color: string | undefined): string | undefined => { + if (color === undefined || color.includes(".")) { + return undefined; + } + + const brightness = perceivedBrightness(color); + + if (brightness === undefined) { + return undefined; + } + + return brightness >= BRIGHTNESS_THRESHOLD ? DARK_TEXT : LIGHT_TEXT; +}; diff --git a/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts index 67fe2c2d2406d..7a2667a7abc23 100644 --- a/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts +++ b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts @@ -41,6 +41,8 @@ export type CustomNodeProps = { team?: string | null; tooltip?: string | null; type: string; + uiColor?: string | null; + uiFgcolor?: string | null; width?: number; }; diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py index 0bde80a22dc37..6aaff421ee38e 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py @@ -33,6 +33,7 @@ from airflow.providers.standard.sensors.external_task import ExternalTaskSensor from airflow.sdk import Metadata, task from airflow.sdk.definitions.asset import Asset, AssetAlias, Dataset +from airflow.sdk.definitions.taskgroup import TaskGroup from tests_common.test_utils.asserts import assert_queries_count from tests_common.test_utils.db import clear_db_assets, clear_db_runs @@ -58,6 +59,8 @@ "team": None, "operator": "EmptyOperator", "asset_condition_type": None, + "ui_color": "#e8f7e4", + "ui_fgcolor": "#000", }, { "children": None, @@ -70,6 +73,8 @@ "team": None, "operator": "EmptyOperator", "asset_condition_type": None, + "ui_color": "#e8f7e4", + "ui_fgcolor": "#000", }, { "children": None, @@ -82,6 +87,8 @@ "team": None, "operator": "EmptyOperator", "asset_condition_type": None, + "ui_color": "#e8f7e4", + "ui_fgcolor": "#000", }, ], } @@ -274,6 +281,8 @@ class TestStructureDataEndpoint: "nodes": [ { "asset_condition_type": None, + "ui_color": "#e8f7e4", + "ui_fgcolor": "#000", "children": None, "id": "task_1", "is_mapped": None, @@ -286,6 +295,8 @@ class TestStructureDataEndpoint: }, { "asset_condition_type": None, + "ui_color": "#4db7db", + "ui_fgcolor": "#000", "children": None, "id": "external_task_sensor", "is_mapped": None, @@ -298,6 +309,8 @@ class TestStructureDataEndpoint: }, { "asset_condition_type": None, + "ui_color": "#e8f7e4", + "ui_fgcolor": "#000", "children": None, "id": "task_2", "is_mapped": None, @@ -332,6 +345,8 @@ class TestStructureDataEndpoint: "nodes": [ { "asset_condition_type": None, + "ui_color": "#e8f7e4", + "ui_fgcolor": "#000", "children": None, "id": "task_1", "is_mapped": None, @@ -361,6 +376,8 @@ class TestStructureDataEndpoint: "nodes": [ { "asset_condition_type": None, + "ui_color": "#ffefeb", + "ui_fgcolor": "#000", "children": None, "id": "trigger_dag_run_operator", "is_mapped": None, @@ -373,6 +390,8 @@ class TestStructureDataEndpoint: }, { "asset_condition_type": None, + "ui_color": None, + "ui_fgcolor": None, "children": None, "id": "trigger:external_trigger:dag_with_multiple_versions:trigger_dag_run_operator", "is_mapped": None, @@ -480,6 +499,8 @@ def test_should_return_200_with_asset(self, test_client, asset1_id, asset2_id, a "team": None, "operator": "EmptyOperator", "asset_condition_type": None, + "ui_color": "#e8f7e4", + "ui_fgcolor": "#000", }, { "children": None, @@ -492,6 +513,8 @@ def test_should_return_200_with_asset(self, test_client, asset1_id, asset2_id, a "team": None, "operator": "ExternalTaskSensor", "asset_condition_type": None, + "ui_color": "#4db7db", + "ui_fgcolor": "#000", }, { "children": None, @@ -504,6 +527,8 @@ def test_should_return_200_with_asset(self, test_client, asset1_id, asset2_id, a "team": None, "operator": "EmptyOperator", "asset_condition_type": None, + "ui_color": "#e8f7e4", + "ui_fgcolor": "#000", }, { "children": None, @@ -516,6 +541,8 @@ def test_should_return_200_with_asset(self, test_client, asset1_id, asset2_id, a "team": None, "operator": None, "asset_condition_type": None, + "ui_color": None, + "ui_fgcolor": None, }, { "children": None, @@ -528,6 +555,8 @@ def test_should_return_200_with_asset(self, test_client, asset1_id, asset2_id, a "team": None, "operator": None, "asset_condition_type": None, + "ui_color": None, + "ui_fgcolor": None, }, { "children": None, @@ -540,6 +569,8 @@ def test_should_return_200_with_asset(self, test_client, asset1_id, asset2_id, a "team": None, "operator": None, "asset_condition_type": None, + "ui_color": None, + "ui_fgcolor": None, }, { "children": None, @@ -552,6 +583,8 @@ def test_should_return_200_with_asset(self, test_client, asset1_id, asset2_id, a "team": None, "operator": None, "asset_condition_type": "and-gate", + "ui_color": None, + "ui_fgcolor": None, }, { "children": None, @@ -564,6 +597,8 @@ def test_should_return_200_with_asset(self, test_client, asset1_id, asset2_id, a "team": None, "operator": None, "asset_condition_type": None, + "ui_color": None, + "ui_fgcolor": None, }, { "children": None, @@ -576,6 +611,8 @@ def test_should_return_200_with_asset(self, test_client, asset1_id, asset2_id, a "team": None, "operator": None, "asset_condition_type": None, + "ui_color": None, + "ui_fgcolor": None, }, { "children": None, @@ -588,6 +625,8 @@ def test_should_return_200_with_asset(self, test_client, asset1_id, asset2_id, a "team": None, "operator": None, "asset_condition_type": None, + "ui_color": None, + "ui_fgcolor": None, }, ], } @@ -637,6 +676,8 @@ def test_should_return_200_with_resolved_asset_alias_attached_to_the_corrrect_pr "setup_teardown_type": None, "operator": "@task", "asset_condition_type": None, + "ui_color": "#ffefeb", + "ui_fgcolor": "#000", }, { "id": "task_2", @@ -649,6 +690,8 @@ def test_should_return_200_with_resolved_asset_alias_attached_to_the_corrrect_pr "setup_teardown_type": None, "operator": "EmptyOperator", "asset_condition_type": None, + "ui_color": "#e8f7e4", + "ui_fgcolor": "#000", }, { "id": f"asset:{resolved_asset.id}", @@ -661,6 +704,8 @@ def test_should_return_200_with_resolved_asset_alias_attached_to_the_corrrect_pr "setup_teardown_type": None, "operator": None, "asset_condition_type": None, + "ui_color": None, + "ui_fgcolor": None, }, ], } @@ -834,6 +879,40 @@ def test_mapped_operator_in_task_group(self, dag_maker, test_client, session): assert mapped_in_group["is_mapped"] is True assert mapped_in_group["operator"] == "PythonOperator" + def test_ui_colors_passed_through_to_graph(self, dag_maker, test_client, session): + """Both raw hex colors and Chakra palette tokens reach the graph unchanged, for operators and groups.""" + + class TokenOperator(EmptyOperator): + ui_color = "blue.500" + ui_fgcolor = "red.700" + + class HexOperator(EmptyOperator): + ui_color = "#e8b7e4" + ui_fgcolor = "#000000" + + with dag_maker( + dag_id="test_ui_colors_dag", + serialized=True, + session=session, + start_date=pendulum.DateTime(2023, 2, 1, 0, 0, 0, tzinfo=pendulum.UTC), + ): + TokenOperator(task_id="token") + HexOperator(task_id="hex") + with TaskGroup(group_id="grp", ui_color="teal.400", ui_fgcolor="#ffffff"): + EmptyOperator(task_id="inner") + + dag_maker.sync_dagbag_to_db() + response = test_client.get("/structure/structure_data", params={"dag_id": "test_ui_colors_dag"}) + assert response.status_code == 200 + nodes = {node["id"]: node for node in response.json()["nodes"]} + + assert nodes["token"]["ui_color"] == "blue.500" + assert nodes["token"]["ui_fgcolor"] == "red.700" + assert nodes["hex"]["ui_color"] == "#e8b7e4" + assert nodes["hex"]["ui_fgcolor"] == "#000000" + assert nodes["grp"]["ui_color"] == "teal.400" + assert nodes["grp"]["ui_fgcolor"] == "#ffffff" + @pytest.mark.parametrize( ("params", "expected_task_ids", "description"), [ diff --git a/airflow-core/tests/unit/utils/test_task_group.py b/airflow-core/tests/unit/utils/test_task_group.py index 2d1458e95fbd1..c0cd58f30dd80 100644 --- a/airflow-core/tests/unit/utils/test_task_group.py +++ b/airflow-core/tests/unit/utils/test_task_group.py @@ -155,9 +155,12 @@ ], } +TASK_COLORS = {"ui_color": "#e8f7e4", "ui_fgcolor": "#000"} +GROUP_COLORS = {"ui_color": "CornflowerBlue", "ui_fgcolor": "#000"} + EXPECTED_JSON = { "children": [ - {"id": "task1", "label": "task1", "operator": "EmptyOperator", "type": "task"}, + {"id": "task1", "label": "task1", "operator": "EmptyOperator", "type": "task", **TASK_COLORS}, { "children": [ { @@ -167,12 +170,14 @@ "label": "task3", "operator": "EmptyOperator", "type": "task", + **TASK_COLORS, }, { "id": "group234.group34.task4", "label": "task4", "operator": "EmptyOperator", "type": "task", + **TASK_COLORS, }, {"id": "group234.group34.downstream_join_id", "label": "", "type": "join"}, ], @@ -181,12 +186,14 @@ "label": "group34", "tooltip": "", "type": "task", + **GROUP_COLORS, }, { "id": "group234.task2", "label": "task2", "operator": "EmptyOperator", "type": "task", + **TASK_COLORS, }, {"id": "group234.upstream_join_id", "label": "", "type": "join"}, ], @@ -195,14 +202,16 @@ "label": "group234", "tooltip": "", "type": "task", + **GROUP_COLORS, }, - {"id": "task5", "label": "task5", "operator": "EmptyOperator", "type": "task"}, + {"id": "task5", "label": "task5", "operator": "EmptyOperator", "type": "task", **TASK_COLORS}, ], "id": None, "is_mapped": False, "label": "", "tooltip": "", "type": "task", + **GROUP_COLORS, } diff --git a/task-sdk/src/airflow/sdk/definitions/taskgroup.py b/task-sdk/src/airflow/sdk/definitions/taskgroup.py index 14bb2fba31918..c1be99c5766bd 100644 --- a/task-sdk/src/airflow/sdk/definitions/taskgroup.py +++ b/task-sdk/src/airflow/sdk/definitions/taskgroup.py @@ -117,8 +117,10 @@ class TaskGroup(DAGNode): `default_args`, the actual value will be `False`. :param tooltip: The tooltip of the TaskGroup node when displayed in the UI :param doc_md: Markdown documentation for the TaskGroup displayed in the UI - :param ui_color: The fill color of the TaskGroup node when displayed in the UI - :param ui_fgcolor: The label color of the TaskGroup node when displayed in the UI + :param ui_color: The fill color of the TaskGroup node in the graph view -- a raw color (hex code + or CSS name) or a Chakra palette or semantic token (e.g. ``blue.500`` or ``brand.solid``) + :param ui_fgcolor: The label color of the TaskGroup node in the graph view -- a raw color (hex + code or CSS name) or a Chakra palette or semantic token :param add_suffix_on_collision: If this task group name already exists, automatically add `__1` etc suffixes :param group_display_name: If set, this will be the display name for the TaskGroup node in the UI. @@ -149,8 +151,14 @@ class TaskGroup(DAGNode): on_setattr=attrs.setters.frozen, ) - ui_color: str = attrs.field(default="CornflowerBlue", validator=attrs.validators.instance_of(str)) - ui_fgcolor: str = attrs.field(default="#000", validator=attrs.validators.instance_of(str)) + ui_color: str = attrs.field( + default="CornflowerBlue", + validator=attrs.validators.instance_of(str), + ) + ui_fgcolor: str = attrs.field( + default="#000", + validator=attrs.validators.instance_of(str), + ) add_suffix_on_collision: bool = False From 75a7410de5cc6a4e91cbe20a5bfc2bb963c278ef Mon Sep 17 00:00:00 2001 From: Andrew Chang <69671930+Andrushika@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:31:24 +0800 Subject: [PATCH 07/86] Skip Go SDK CI jobs for doc-only changes under go-sdk/ (#70021) go-sdk/*.md edits (README, ADRs) currently match the Go SDK file groups and trigger the Go unit + e2e suites plus a prod image build, none of which a documentation change can affect. Excluding .md also lets non-.go build files (go.mod, go.sum, build config) trigger the unit tests, which the previous .go-only pattern missed. --- .../airflow_breeze/utils/selective_checks.py | 7 +++-- dev/breeze/tests/test_selective_checks.py | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py b/dev/breeze/src/airflow_breeze/utils/selective_checks.py index 17e3fca8e2abb..7e2e83ed0051a 100644 --- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py +++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py @@ -247,7 +247,8 @@ def __hash__(self): r"^task-sdk/src/airflow/sdk/coordinators/java/.*", ], FileGroupForCi.GO_SDK_E2E_FILES: [ - r"^go-sdk/.*", + # `.md` excluded — doc-only edits do not affect the Go build or e2e tests. + r"^go-sdk/(?!.*\.md$).*", r"^airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/.*", r"^airflow-e2e-tests/docker/go\.yml$", r"^task-sdk/src/airflow/sdk/coordinators/_subprocess\.py$", @@ -452,7 +453,9 @@ def __hash__(self): r"^task-sdk-integration-tests/.*\.py$", ], FileGroupForCi.GO_SDK_FILES: [ - r"^go-sdk/.*\.go$", + # `.md` excluded — doc-only edits do not affect the Go build or tests, but + # everything else (go.mod, go.sum, build config) must trigger the unit tests. + r"^go-sdk/(?!.*\.md$).*", ], FileGroupForCi.JAVA_SDK_FILES: [ # `.md` excluded — doc-only edits do not affect the Gradle build. diff --git a/dev/breeze/tests/test_selective_checks.py b/dev/breeze/tests/test_selective_checks.py index 60371125ade90..385e7ba99a81c 100644 --- a/dev/breeze/tests/test_selective_checks.py +++ b/dev/breeze/tests/test_selective_checks.py @@ -1477,6 +1477,33 @@ def assert_outputs_are_printed(expected_outputs: dict[str, str], stderr: str): }, id="Run go unit and e2e tests for go-sdk source change", ), + pytest.param( + ("go-sdk/go.mod",), + { + "run-go-sdk-tests": "true", + "run-go-sdk-e2e-tests": "true", + "prod-image-build": "true", + }, + id="Run go unit and e2e tests for go-sdk dependency change", + ), + pytest.param( + ("go-sdk/README.md",), + { + "run-go-sdk-tests": "false", + "run-go-sdk-e2e-tests": "false", + "prod-image-build": "false", + }, + id="Skip go unit and e2e tests for go-sdk README-only change", + ), + pytest.param( + ("go-sdk/adr/0001-bundle-packing-options.md",), + { + "run-go-sdk-tests": "false", + "run-go-sdk-e2e-tests": "false", + "prod-image-build": "false", + }, + id="Skip go unit and e2e tests for go-sdk ADR-only change", + ), pytest.param( ("airflow-e2e-tests/docker/go.yml",), { From 3705860d2df2eaaf334b8a82a13a21aef8806c1e Mon Sep 17 00:00:00 2001 From: Jake McGrath <116606359+jroachgolf84@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:38:42 -0400 Subject: [PATCH 08/86] Enhance/standardize documentation for `AssetOrTimeSchedule` (#69831) --- .../docs/authoring-and-scheduling/timetable.rst | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/airflow-core/docs/authoring-and-scheduling/timetable.rst b/airflow-core/docs/authoring-and-scheduling/timetable.rst index b19cc80211392..e5ae326f34e3e 100644 --- a/airflow-core/docs/authoring-and-scheduling/timetable.rst +++ b/airflow-core/docs/authoring-and-scheduling/timetable.rst @@ -264,8 +264,9 @@ first, event for the data interval. Otherwise, manual runs begin with a ``data_i .. _asset-timetable-section: -Asset event based scheduling with time based scheduling -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +AssetOrTimeSchedule +^^^^^^^^^^^^^^^^^^^ + Combining conditional asset expressions with time-based schedules enhances scheduling flexibility. The ``AssetOrTimeSchedule`` is a specialized timetable that allows for the scheduling of Dags based on both time-based schedules and asset events. It also facilitates the creation of both scheduled runs, as per traditional timetables, and asset-triggered runs, which operate independently. @@ -283,15 +284,13 @@ Here's an example of a Dag using ``AssetOrTimeSchedule``: @dag( schedule=AssetOrTimeSchedule( timetable=CronTriggerTimetable("0 1 * * 3", timezone="UTC"), assets=(dag1_asset & dag2_asset) - ) - # Additional arguments here, replace this comment with actual arguments + ), + ..., ) def example_dag(): - # Dag tasks go here pass - Timetables comparisons ---------------------- From e633f7cd9883fad9d14cbdfd80dc8c50783d592d Mon Sep 17 00:00:00 2001 From: Yuseok Jo Date: Sat, 18 Jul 2026 00:39:04 +0900 Subject: [PATCH 09/86] UI: Lazy-mount the pause confirmation dialog on Dag cards and rows (#70016) --- .../src/airflow/ui/src/components/ConfirmationModal.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/ui/src/components/ConfirmationModal.tsx b/airflow-core/src/airflow/ui/src/components/ConfirmationModal.tsx index f4c8a92007fe9..f862929fbcc95 100644 --- a/airflow-core/src/airflow/ui/src/components/ConfirmationModal.tsx +++ b/airflow-core/src/airflow/ui/src/components/ConfirmationModal.tsx @@ -33,7 +33,13 @@ export const ConfirmationModal = ({ children, header, onConfirm, onOpenChange, o const { t: translate } = useTranslation("common"); return ( - + {header} From aa064d2d1fb98c0d0011216c3bb8117ed199917b Mon Sep 17 00:00:00 2001 From: Andrew Chang <69671930+Andrushika@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:54:07 +0800 Subject: [PATCH 10/86] Skip ts-sdk supervisor schema check for doc-only changes (#70022) ts-sdk/*.md edits (e.g. README) currently match TS_SDK_FILES and keep the check-ts-sdk-supervisor-schema prek hook from being skipped, so a documentation-only change needlessly regenerates and diffs the generated supervisor schema. Documentation does not affect it. --- .../src/airflow_breeze/utils/selective_checks.py | 3 ++- dev/breeze/tests/test_selective_checks.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py b/dev/breeze/src/airflow_breeze/utils/selective_checks.py index 7e2e83ed0051a..b912af64e3d9f 100644 --- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py +++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py @@ -462,7 +462,8 @@ def __hash__(self): r"^java-sdk/(?!.*\.md$).*", ], FileGroupForCi.TS_SDK_FILES: [ - r"^ts-sdk/", + # `.md` excluded — doc-only edits do not affect the generated supervisor schema. + r"^ts-sdk/(?!.*\.md$).*", ], FileGroupForCi.ASSET_FILES: [ r"^airflow-core/src/airflow/assets/", diff --git a/dev/breeze/tests/test_selective_checks.py b/dev/breeze/tests/test_selective_checks.py index 385e7ba99a81c..ae8260db30fc9 100644 --- a/dev/breeze/tests/test_selective_checks.py +++ b/dev/breeze/tests/test_selective_checks.py @@ -1788,6 +1788,16 @@ def test_ktlint_hook_only_runs_for_java_sdk_changes(files: tuple[str, ...], ktli True, id="skipped when no ts-sdk files change", ), + pytest.param( + ("ts-sdk/README.md",), + True, + id="skipped when only ts-sdk docs change", + ), + pytest.param( + ("ts-sdk/example/README.md",), + True, + id="skipped when only nested ts-sdk docs change", + ), ], ) def test_check_ts_sdk_supervisor_schema_hook_only_runs_for_relevant_changes( From 62033aef36deb07c878d034611c4b2d6c7cfb61e Mon Sep 17 00:00:00 2001 From: Daniel Standish <15932138+dstandish@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:24:52 -0700 Subject: [PATCH 11/86] Use atomic upsert for asset run queue on MySQL to avoid deadlocks (#69977) Under concurrent asset-event fan-out, the per-row SAVEPOINT loop used for non-PostgreSQL backends deadlocks on MySQL/InnoDB and surfaces confusing "SAVEPOINT ... does not exist" errors, and the deadlock is not retried. Route MySQL to a single-statement INSERT ... ON DUPLICATE KEY UPDATE, mirroring the existing PostgreSQL path, so the enqueue is atomic and holds locks far more briefly. closes: #69938 --- airflow-core/src/airflow/assets/manager.py | 29 ++++++++++--- .../tests/unit/assets/test_manager.py | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/airflow-core/src/airflow/assets/manager.py b/airflow-core/src/airflow/assets/manager.py index c3491574e9148..1dd26379b9d12 100644 --- a/airflow-core/src/airflow/assets/manager.py +++ b/airflow-core/src/airflow/assets/manager.py @@ -518,12 +518,17 @@ def _queue_dagruns( # mapped) tasks update the same asset, this can fail with a unique # constraint violation. # - # If we support it, use ON CONFLICT to do nothing, otherwise - # "fallback" to running this in a nested transaction. This is needed - # so that the adding of these rows happens in the same transaction - # where `ti.state` is changed. - if get_dialect_name(session) == "postgresql": + # Where the dialect supports a single-statement "insert, ignore on + # conflict" we use it; it is atomic, avoids the per-row SAVEPOINT churn, + # and holds locks for far less time (which on MySQL/InnoDB also makes the + # concurrent fan-out much less deadlock-prone). Otherwise we "fallback" to + # a nested transaction per row. Either way the rows are added in the same + # transaction where `ti.state` is changed. + dialect_name = get_dialect_name(session) + if dialect_name == "postgresql": return cls._queue_dagruns_nonpartitioned_postgres(asset_id, non_partitioned_dags, session) + if dialect_name == "mysql": + return cls._queue_dagruns_nonpartitioned_mysql(asset_id, non_partitioned_dags, session) return cls._queue_dagruns_nonpartitioned_slow_path(asset_id, non_partitioned_dags, session) @classmethod @@ -817,6 +822,20 @@ def _queue_dagruns_nonpartitioned_postgres( stmt = insert(AssetDagRunQueue).values(asset_id=asset_id).on_conflict_do_nothing() session.execute(stmt, values) + @classmethod + def _queue_dagruns_nonpartitioned_mysql( + cls, asset_id: int, dags_to_queue: set[DagModel], session: Session + ) -> None: + from sqlalchemy.dialects.mysql import insert + + values = [{"target_dag_id": dag.dag_id} for dag in dags_to_queue] + stmt = insert(AssetDagRunQueue).values(asset_id=asset_id) + # MySQL has no "ON CONFLICT DO NOTHING"; a no-op ON DUPLICATE KEY UPDATE turns a + # conflicting (asset_id, target_dag_id) row into a no-op rather than an error, + # matching the Postgres path. + stmt = stmt.on_duplicate_key_update(target_dag_id=stmt.inserted.target_dag_id) + session.execute(stmt, values) + def resolve_asset_manager() -> AssetManager: """Retrieve the asset manager.""" diff --git a/airflow-core/tests/unit/assets/test_manager.py b/airflow-core/tests/unit/assets/test_manager.py index b788b9ab28699..c290d7f5a6328 100644 --- a/airflow-core/tests/unit/assets/test_manager.py +++ b/airflow-core/tests/unit/assets/test_manager.py @@ -26,6 +26,7 @@ import pytest from sqlalchemy import delete, func, select +from sqlalchemy.dialects import mysql from sqlalchemy.orm import Session from airflow import settings @@ -211,6 +212,46 @@ def test_register_asset_change_no_downstreams(self, session, mock_task_instance) ) assert session.scalar(select(func.count()).select_from(AssetDagRunQueue)) == 0 + @pytest.mark.parametrize( + ("dialect_name", "expected_helper"), + [ + ("postgresql", "_queue_dagruns_nonpartitioned_postgres"), + ("mysql", "_queue_dagruns_nonpartitioned_mysql"), + ("sqlite", "_queue_dagruns_nonpartitioned_slow_path"), + ], + ) + def test_queue_dagruns_routes_by_dialect(self, dialect_name, expected_helper): + """Test that _queue_dagruns routes to the dialect-appropriate queue helper.""" + dag = DagModel(dag_id="dag1") + session = mock.MagicMock(spec=Session) + with ( + mock.patch("airflow.assets.manager.get_dialect_name", return_value=dialect_name), + mock.patch.object(AssetManager, "_queue_partitioned_dags"), + mock.patch.object(AssetManager, expected_helper) as mock_helper, + ): + AssetManager._queue_dagruns( + asset_id=1, + dags_to_queue={dag}, + partition_key=None, + partition_date=None, + event=mock.MagicMock(), + task_instance=None, + session=session, + ) + mock_helper.assert_called_once_with(1, {dag}, session) + + def test_queue_dagruns_nonpartitioned_mysql_builds_upsert(self): + """Test that the MySQL queue path emits an INSERT ... ON DUPLICATE KEY UPDATE.""" + dag = DagModel(dag_id="dag1") + session = mock.MagicMock(spec=Session) + + AssetManager._queue_dagruns_nonpartitioned_mysql(asset_id=1, dags_to_queue={dag}, session=session) + + stmt, values = session.execute.call_args.args + compiled = str(stmt.compile(dialect=mysql.dialect())).upper() + assert "ON DUPLICATE KEY UPDATE" in compiled + assert values == [{"target_dag_id": "dag1"}] + def test_register_asset_change_notifies_asset_listener( self, session, mock_task_instance, testing_dag_bundle, listener_manager ): From 77556d3c0e5927b9184674aca632145299cc67e7 Mon Sep 17 00:00:00 2001 From: Niko Oliveira Date: Fri, 17 Jul 2026 12:35:30 -0700 Subject: [PATCH 12/86] Pin task bundle manifest to the dagrun's version (#69941) A mid-run DAG re-parse possibly creates a new DagVersion and bumps not-yet-started task instances' dag_version_id, while the DagRun stays pinned to its original bundle_version. ExecuteTask.make() sourced the bundle version from the run's pin (dag_run.bundle_version) but bundle version_data from the task's now-bumped dag_version, so the workload shipped a hash and a manifest that describe different versions. This double source was noticed for the callback path in a previous PR, but the task path was missed. Source version_data from the run's pinned created_dag_version so it always matches the bundle version, mirroring ExecuteCallback.make() and the way Git bundles resolve everything from the run's pinned commit. --- .../src/airflow/executors/workloads/task.py | 6 +- .../src/airflow/jobs/scheduler_job_runner.py | 17 ++++-- .../tests/unit/executors/test_workloads.py | 55 +++++++++++++++---- .../tests/unit/jobs/test_scheduler_job.py | 37 ++++++++++++- .../executors/batch/test_batch_executor.py | 2 + .../aws/executors/ecs/test_ecs_executor.py | 2 + 6 files changed, 99 insertions(+), 20 deletions(-) diff --git a/airflow-core/src/airflow/executors/workloads/task.py b/airflow-core/src/airflow/executors/workloads/task.py index 68a917118e39c..3099fe1d77485 100644 --- a/airflow-core/src/airflow/executors/workloads/task.py +++ b/airflow-core/src/airflow/executors/workloads/task.py @@ -107,7 +107,11 @@ def make( bundle_info = BundleInfo( name=ti.dag_model.bundle_name, version=ti.dag_run.bundle_version, - version_data=_resolve_version_data(ti.dag_version, ti.dag_run.bundle_version), + # Source version_data from the run's pinned version (matching ``version`` above), + # not the TI's dag_version. A mid-run DAG re-parse can bump the TI's dag_version + # to a newer version while the run stays pinned; sourcing from created_dag_version + # keeps the shipped hash and manifest consistent so versioned bundles stay reproducible. + version_data=_resolve_version_data(ti.dag_run.created_dag_version, ti.dag_run.bundle_version), ) fname = log_filename_template_renderer()(ti=ti) diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py b/airflow-core/src/airflow/jobs/scheduler_job_runner.py index f6d6a4787f629..c2f3ee05d730e 100644 --- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py +++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py @@ -684,12 +684,17 @@ def _executable_task_instances_to_queued(self, max_tis: int, session: Session) - ranked_query.c.map_index_for_ordering, ) .options(selectinload(TI.dag_model)) - # Eager-load dag_version: TIs become transient (via make_transient) before - # ExecuteTask.make() reads ti.dag_version.version_data. Lazy loads on - # transient objects silently return None instead of raising DetachedInstanceError. - # Scope the second SELECT to version_data (the PK is auto-included) so we read - # two columns rather than the full DagVersion row. - .options(selectinload(TI.dag_version).load_only(DagVersion.version_data)) + # Eager-load the run's pinned DagVersion (dag_run.created_dag_version): TIs become + # transient (via make_transient) before ExecuteTask.make() reads + # ti.dag_run.created_dag_version.version_data to ship the bundle manifest matching + # the run's pinned bundle_version. Lazy loads on transient objects silently return + # None instead of raising DetachedInstanceError. Scope the SELECT to version_data + # (the PK is auto-included) so we read two columns rather than the full row. + .options( + joinedload(TI.dag_run) + .selectinload(DagRun.created_dag_version) + .load_only(DagVersion.version_data) + ) ) query = query.limit(max_tis) diff --git a/airflow-core/tests/unit/executors/test_workloads.py b/airflow-core/tests/unit/executors/test_workloads.py index 1ef04477d081c..37fbcd96ce950 100644 --- a/airflow-core/tests/unit/executors/test_workloads.py +++ b/airflow-core/tests/unit/executors/test_workloads.py @@ -184,12 +184,21 @@ def _stub_log_template(self, monkeypatch): ) @staticmethod - def _make_mock_ti(bundle_version, version_data, *, has_dag_version=True): + def _make_mock_ti( + bundle_version, + version_data, + *, + has_created_dag_version=True, + ti_dag_version_data=None, + ): """Build a mock TI with the attributes ExecuteTask.make() reads. - ``has_dag_version`` controls whether the TI has an associated DagVersion - (legacy/backfilled TIs may not), independently of ``version_data`` so the - pin-guard can be exercised with version_data present on an unpinned run. + ``version_data`` is the manifest on the run's pinned version + (``dag_run.created_dag_version``) -- the source make() must use. + ``ti_dag_version_data`` is the manifest on ``ti.dag_version``, which make() + must IGNORE (it can diverge from the run's pin after a mid-run DAG re-parse). + ``has_created_dag_version`` toggles whether the run has a pinned DagVersion + (legacy/backfilled runs may not). """ from unittest.mock import Mock @@ -215,15 +224,18 @@ def _make_mock_ti(bundle_version, version_data, *, has_dag_version=True): ti.dag_run.bundle_version = bundle_version - if has_dag_version: - ti.dag_version.version_data = version_data + # make() must source version_data from the run's pinned version, never ti.dag_version. + ti.dag_version.version_data = ti_dag_version_data + + if has_created_dag_version: + ti.dag_run.created_dag_version.version_data = version_data else: - ti.dag_version = None + ti.dag_run.created_dag_version = None return ti def test_pinned_run_populates_version_data(self): - """When the run is pinned, version_data from dag_version flows to BundleInfo.""" + """When the run is pinned, version_data from the run's created_dag_version flows to BundleInfo.""" version_data = {"schema_version": 1, "files": {"dags/my_dag.py": "ver123"}} ti = self._make_mock_ti(bundle_version="abc123", version_data=version_data) @@ -233,7 +245,7 @@ def test_pinned_run_populates_version_data(self): assert workload.bundle_info.version_data == version_data def test_unpinned_run_suppresses_present_version_data(self): - """An unpinned run must not expose version_data even when the dag_version carries it.""" + """An unpinned run must not expose version_data even when created_dag_version carries it.""" version_data = {"schema_version": 1, "files": {"dags/my_dag.py": "ver123"}} ti = self._make_mock_ti(bundle_version=None, version_data=version_data) @@ -242,15 +254,34 @@ def test_unpinned_run_suppresses_present_version_data(self): assert workload.bundle_info.version is None assert workload.bundle_info.version_data is None - def test_missing_dag_version_yields_none(self): - """A pinned run whose TI has no dag_version (legacy/backfilled) yields no version_data.""" - ti = self._make_mock_ti(bundle_version="abc123", version_data=None, has_dag_version=False) + def test_missing_created_dag_version_yields_none(self): + """A pinned run whose DagRun has no created_dag_version yields no version_data.""" + ti = self._make_mock_ti(bundle_version="abc123", version_data=None, has_created_dag_version=False) workload = ExecuteTask.make(ti) assert workload.bundle_info.version == "abc123" assert workload.bundle_info.version_data is None + def test_mid_run_dag_version_bump_uses_run_pinned_manifest(self): + """Regression: a mid-run DAG re-parse can bump ti.dag_version to a newer version while the + run stays pinned. make() must ship the run's pinned manifest (created_dag_version), not the + TI's bumped one -- otherwise a versioned bundle would fetch the wrong (latest) code. + """ + run_manifest = {"schema_version": 1, "files": {"dags/my_dag.py": "v1-object-id"}} + bumped_manifest = {"schema_version": 1, "files": {"dags/my_dag.py": "v2-object-id"}} + ti = self._make_mock_ti( + bundle_version="v1hash", + version_data=run_manifest, + ti_dag_version_data=bumped_manifest, + ) + + workload = ExecuteTask.make(ti) + + assert workload.bundle_info.version == "v1hash" + assert workload.bundle_info.version_data == run_manifest + assert workload.bundle_info.version_data != bumped_manifest + class TestExecuteCallbackMakeVersionData: """Tests for ExecuteCallback.make() threading version_data through BundleInfo.""" diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py b/airflow-core/tests/unit/jobs/test_scheduler_job.py index 1f3b6992c3e1c..68f4ec0915092 100644 --- a/airflow-core/tests/unit/jobs/test_scheduler_job.py +++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py @@ -142,7 +142,7 @@ from airflow.utils.types import DagRunTriggeredByType, DagRunType from tests_common.pytest_plugin import AIRFLOW_ROOT_PATH -from tests_common.test_utils.asserts import assert_queries_count +from tests_common.test_utils.asserts import assert_queries_count, count_queries from tests_common.test_utils.config import conf_vars, env_vars from tests_common.test_utils.dag import create_scheduler_dag, sync_dag_to_db, sync_dags_to_db from tests_common.test_utils.db import ( @@ -1425,6 +1425,41 @@ def test_find_executable_task_instances_backfill(self, dag_maker): assert {x.key for x in queued_tis} == {ti_non_backfill.key, ti_backfill.key} session.rollback() + def test_executable_task_instances_no_per_ti_queries(self, dag_maker, session): + """Guard against an N+1 when enqueuing task instances. + + ``ExecuteTask.make()`` reads ``ti.dag_run.created_dag_version.version_data`` to ship the + run's pinned bundle manifest. ``dag_run`` is eager-joined and ``created_dag_version`` is a + single batched ``selectin``, so the number of queries in + ``_executable_task_instances_to_queued`` must be independent of how many task instances are + in the batch. If a future change lazy-loads ``dag_run``/``created_dag_version`` per TI, the + count would scale with the task count and this test fails. + """ + scheduler_job = Job() + runner = SchedulerJobRunner(job=scheduler_job) + self.job_runner = runner + + def _measure(dag_id: str, num_tasks: int) -> int: + with dag_maker(dag_id=dag_id, max_active_tasks=64, session=session): + for i in range(num_tasks): + EmptyOperator(task_id=f"t{i}") + dr = dag_maker.create_dagrun(run_type=DagRunType.SCHEDULED) + for ti in dr.task_instances: + ti.state = State.SCHEDULED + session.flush() + with count_queries(session=session) as result: + runner._executable_task_instances_to_queued(max_tis=64, session=session) + session.rollback() + return sum(result.values()) + + one_task = _measure("q_count_one", 1) + many_tasks = _measure("q_count_many", 10) + + assert one_task == many_tasks, ( + f"query count scaled with task-instance count ({one_task} -> {many_tasks}); " + "likely a per-TI lazy load (N+1) of dag_run/created_dag_version" + ) + def test_find_executable_task_instances_mysql_hint_only_applies_to_inner_query(self, dag_maker, session): dag_id = "SchedulerJobTest.test_find_executable_task_instances_mysql_hint_only_applies_to_inner_query" task_id = "dummy" diff --git a/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py b/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py index f5800232610ca..4bfd7c716efa0 100644 --- a/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py +++ b/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py @@ -817,6 +817,8 @@ def test_try_adopt_task_instances(self, mock_executor): task.dag_version = mock.Mock(version_data=None) task.dag_run = mock.Mock() task.dag_run.bundle_version = "1.0.0" + # ExecuteTask.make() sources version_data from the run's pinned version. + task.dag_run.created_dag_version = mock.Mock(version_data=None) task.dag_run.context_carrier = {} if not AIRFLOW_V_3_0_PLUS: diff --git a/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py b/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py index b7c25d51277b4..b6074b8a55402 100644 --- a/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py +++ b/providers/amazon/tests/unit/amazon/aws/executors/ecs/test_ecs_executor.py @@ -1312,6 +1312,8 @@ def test_try_adopt_task_instances(self, mock_executor): task.dag_version = mock.Mock(version_data=None) task.dag_run = mock.Mock() task.dag_run.bundle_version = "1.0.0" + # ExecuteTask.make() sources version_data from the run's pinned version. + task.dag_run.created_dag_version = mock.Mock(version_data=None) task.dag_run.context_carrier = {} # Mock command generation based on Airflow version From ca41c1de46f84989bcf8ad2a1d0aafc7a89df44f Mon Sep 17 00:00:00 2001 From: Aaron Chen Date: Sat, 18 Jul 2026 03:50:51 +0800 Subject: [PATCH 13/86] Allow configuring XCom sidecar container security context (#69613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow configuring XCom sidecar container security context * fix CI error * fix CI error #2 * Update providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py Co-authored-by: Przemysław Mirowski <17602603+Miretpl@users.noreply.github.com> * fix CI error --------- Co-authored-by: Przemysław Mirowski <17602603+Miretpl@users.noreply.github.com> --- .../unit/always/test_project_structure.py | 1 - docs/spelling_wordlist.txt | 1 + .../test_kubernetes_pod_operator.py | 1 + .../docs/connections/kubernetes.rst | 11 +++ providers/cncf/kubernetes/docs/operators.rst | 6 ++ providers/cncf/kubernetes/provider.yaml | 6 ++ .../cncf/kubernetes/get_provider_info.py | 4 ++ .../cncf/kubernetes/hooks/kubernetes.py | 13 ++++ .../cncf/kubernetes/operators/pod.py | 9 +++ .../cncf/kubernetes/utils/xcom_sidecar.py | 3 + .../decorators/test_kubernetes_commons.py | 1 + .../cncf/kubernetes/hooks/test_kubernetes.py | 34 +++++++++ .../cncf/kubernetes/operators/test_pod.py | 55 ++++++++++++++ .../kubernetes/utils/test_xcom_sidecar.py | 71 +++++++++++++++++++ .../sdk/definitions/decorators/__init__.pyi | 6 ++ 15 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_xcom_sidecar.py diff --git a/airflow-core/tests/unit/always/test_project_structure.py b/airflow-core/tests/unit/always/test_project_structure.py index 9286bdb5e41a5..2a599f9759e88 100644 --- a/airflow-core/tests/unit/always/test_project_structure.py +++ b/airflow-core/tests/unit/always/test_project_structure.py @@ -97,7 +97,6 @@ def test_providers_modules_should_have_tests(self): "providers/cncf/kubernetes/tests/unit/cncf/kubernetes/triggers/test_kubernetes_pod.py", "providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_delete_from.py", "providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_k8s_hashlib_wrapper.py", - "providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_xcom_sidecar.py", "providers/common/sql/tests/unit/common/sql/datafusion/test_base.py", "providers/common/sql/tests/unit/common/sql/datafusion/test_exceptions.py", "providers/common/ai/tests/unit/common/ai/test_exceptions.py", diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 3382600169700..f9c2156a29ebb 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -1175,6 +1175,7 @@ onboarded onboarding OnFailure Oozie +OPA OpenAI openai openapi diff --git a/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py b/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py index ceb6e9cb1ae56..97dc16925f0ad 100644 --- a/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py +++ b/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py @@ -964,6 +964,7 @@ def test_pod_template_file( hook_mock.return_value.is_in_cluster = False hook_mock.return_value.get_xcom_sidecar_container_image.return_value = None hook_mock.return_value.get_xcom_sidecar_container_resources.return_value = None + hook_mock.return_value.get_xcom_sidecar_container_security_context.return_value = None hook_mock.return_value.get_connection.return_value = Connection(conn_id="kubernetes_default") extract_xcom_mock.return_value = "{}" k = KubernetesPodOperator( diff --git a/providers/cncf/kubernetes/docs/connections/kubernetes.rst b/providers/cncf/kubernetes/docs/connections/kubernetes.rst index cb1caa4b63480..66b13263ccb8b 100644 --- a/providers/cncf/kubernetes/docs/connections/kubernetes.rst +++ b/providers/cncf/kubernetes/docs/connections/kubernetes.rst @@ -71,6 +71,17 @@ Xcom sidecar image Define the ``image`` used by the ``PodDefaults.SIDECAR_CONTAINER`` (defaults to ``"alpine"``) to allow private repositories, as well as custom image overrides. +Xcom sidecar resources (JSON format) + Define the resource ``requests``/``limits`` for the XCom sidecar container as a JSON object, e.g. + ``{"requests": {"cpu": "1m", "memory": "10Mi"}}``. + +Xcom sidecar security context (JSON format) + Define the ``securityContext`` for the XCom sidecar container as a JSON object, e.g. + ``{"allowPrivilegeEscalation": false, "readOnlyRootFilesystem": true, "seccompProfile": {"type": "RuntimeDefault"}}``. + Useful when clusters enforce Pod Security Standards or admission policies (e.g. OPA/Gatekeeper) on the + injected sidecar. ``KubernetesPodOperator`` Dag authors can override this per task via the + ``xcom_sidecar_container_security_context`` argument. + Example storing connection in env var using URI format: .. code-block:: bash diff --git a/providers/cncf/kubernetes/docs/operators.rst b/providers/cncf/kubernetes/docs/operators.rst index a3f8e417237e8..0eb7dcedc4b42 100644 --- a/providers/cncf/kubernetes/docs/operators.rst +++ b/providers/cncf/kubernetes/docs/operators.rst @@ -197,6 +197,12 @@ alongside the Pod. The Pod must write the XCom value into this location at the ` .. note:: An invalid json content will fail, example ``echo 'hello' > /airflow/xcom/return.json`` fail and ``echo '\"hello\"' > /airflow/xcom/return.json`` work +.. note:: + In clusters that enforce Pod Security Standards or admission policies (e.g. OPA/Gatekeeper), the injected + XCom sidecar container may be rejected unless it declares a security context. Set a cluster-wide default via + the ``xcom_sidecar_container_security_context`` field on the Kubernetes connection, or override it per task + with the ``xcom_sidecar_container_security_context`` argument of ``KubernetesPodOperator``. + See the following example on how this occurs: diff --git a/providers/cncf/kubernetes/provider.yaml b/providers/cncf/kubernetes/provider.yaml index 584f015dcda84..047f6b410c00b 100644 --- a/providers/cncf/kubernetes/provider.yaml +++ b/providers/cncf/kubernetes/provider.yaml @@ -233,6 +233,12 @@ connection-types: type: - string - 'null' + xcom_sidecar_container_security_context: + label: XCom sidecar security context (JSON format) + schema: + type: + - string + - 'null' ui-field-behaviour: hidden-fields: - host diff --git a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py index 73b9f1ea81470..34c9857a3151b 100644 --- a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py +++ b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/get_provider_info.py @@ -108,6 +108,10 @@ def get_provider_info(): "label": "XCom sidecar resources (JSON format)", "schema": {"type": ["string", "null"]}, }, + "xcom_sidecar_container_security_context": { + "label": "XCom sidecar security context (JSON format)", + "schema": {"type": ["string", "null"]}, + }, }, "ui-field-behaviour": { "hidden-fields": ["host", "schema", "login", "password", "port", "extra"] diff --git a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py index 9dcb6b59bba0a..7c8140a6f0796 100644 --- a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py +++ b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py @@ -152,6 +152,9 @@ def get_xcom_sidecar_container_image(self) -> str | None: def get_xcom_sidecar_container_resources(self) -> str | None: """Return the xcom sidecar resources that defined in the connection.""" + def get_xcom_sidecar_container_security_context(self) -> str | None: + """Return the xcom sidecar security context that defined in the connection.""" + class KubernetesHook(BaseHook, PodOperatorHookProtocol): """ @@ -213,6 +216,9 @@ def get_connection_form_widgets(cls) -> dict[str, Any]: "xcom_sidecar_container_resources": StringField( lazy_gettext("XCom sidecar resources (JSON format)"), widget=BS3TextFieldWidget() ), + "xcom_sidecar_container_security_context": StringField( + lazy_gettext("XCom sidecar security context (JSON format)"), widget=BS3TextFieldWidget() + ), } @classmethod @@ -526,6 +532,13 @@ def get_xcom_sidecar_container_resources(self): return None return json.loads(field) + def get_xcom_sidecar_container_security_context(self): + """Return the xcom sidecar security context that defined in the connection.""" + field = self._get_field("xcom_sidecar_container_security_context") + if not field: + return None + return json.loads(field) + def get_pod_log_stream( self, pod_name: str, diff --git a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py index 9f59fcb66d368..b5843fee2688c 100644 --- a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py +++ b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py @@ -204,6 +204,8 @@ class KubernetesPodOperator(BaseOperator): :param tolerations: A list of kubernetes tolerations. :param security_context: security options the pod should run with (PodSecurityContext). :param container_security_context: security options the container should run with. + :param xcom_sidecar_container_security_context: security options the xcom sidecar container should + run with. Overrides the value configured on the Kubernetes connection. :param dnspolicy: dnspolicy for the pod. :param dns_config: dns configuration (ip addresses, searches, options) for the pod. :param hostname: hostname for the pod. (templated) @@ -343,6 +345,7 @@ def __init__( tolerations: list[k8s.V1Toleration] | None = None, security_context: k8s.V1PodSecurityContext | dict | None = None, container_security_context: k8s.V1SecurityContext | dict | None = None, + xcom_sidecar_container_security_context: k8s.V1SecurityContext | dict | None = None, dnspolicy: str | None = None, dns_config: k8s.V1PodDNSConfig | None = None, hostname: str | None = None, @@ -431,6 +434,7 @@ def __init__( ) self.security_context = security_context or {} self.container_security_context = container_security_context + self.xcom_sidecar_container_security_context = xcom_sidecar_container_security_context self.dnspolicy = dnspolicy self.dns_config = dns_config self.hostname = hostname @@ -1555,6 +1559,11 @@ def build_pod_request_obj(self, context: Context | None = None) -> k8s.V1Pod: pod, sidecar_container_image=self.hook.get_xcom_sidecar_container_image(), sidecar_container_resources=self.hook.get_xcom_sidecar_container_resources(), + sidecar_container_security_context=( + self.xcom_sidecar_container_security_context + if self.xcom_sidecar_container_security_context is not None + else self.hook.get_xcom_sidecar_container_security_context() + ), ) labels = self._get_ti_pod_labels(context) diff --git a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/xcom_sidecar.py b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/xcom_sidecar.py index 98745efad722c..5bf26975b32c3 100644 --- a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/xcom_sidecar.py +++ b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/xcom_sidecar.py @@ -58,6 +58,7 @@ def add_xcom_sidecar( *, sidecar_container_image: str | None = None, sidecar_container_resources: k8s.V1ResourceRequirements | dict | None = None, + sidecar_container_security_context: k8s.V1SecurityContext | dict | None = None, ) -> k8s.V1Pod: """Add sidecar.""" pod_cp = copy.deepcopy(pod) @@ -69,6 +70,8 @@ def add_xcom_sidecar( sidecar.image = sidecar_container_image or PodDefaults.SIDECAR_CONTAINER.image if sidecar_container_resources: sidecar.resources = sidecar_container_resources + if sidecar_container_security_context is not None: + sidecar.security_context = sidecar_container_security_context pod_cp.spec.containers.append(sidecar) return pod_cp diff --git a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/decorators/test_kubernetes_commons.py b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/decorators/test_kubernetes_commons.py index 9a23349956f91..d6ffbd3a81f6c 100644 --- a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/decorators/test_kubernetes_commons.py +++ b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/decorators/test_kubernetes_commons.py @@ -111,6 +111,7 @@ def setup(self, dag_maker): } ) self.mock_hook = mock.patch(HOOK_CLASS).start() + self.mock_hook.return_value.get_xcom_sidecar_container_security_context.return_value = None # Without this patch each time pod manager would try to extract logs from the pod # and log an error about it's inability to get containers for the log diff --git a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py index de9670f490371..d22dc067868f8 100644 --- a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py +++ b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/hooks/test_kubernetes.py @@ -227,6 +227,19 @@ def setup_connections(self, create_connection_without_db): }, ), ("sidecar_container_resources_empty", {"xcom_sidecar_container_resources": ""}), + ( + "sidecar_container_security_context", + { + "xcom_sidecar_container_security_context": json.dumps( + { + "allowPrivilegeEscalation": False, + "readOnlyRootFilesystem": True, + "seccompProfile": {"type": "RuntimeDefault"}, + } + ), + }, + ), + ("sidecar_container_security_context_empty", {"xcom_sidecar_container_security_context": ""}), ] for conn_id, extra in connections: create_connection_without_db( @@ -560,6 +573,27 @@ def test_get_xcom_sidecar_container_resources(self, conn_id, expected): hook = KubernetesHook(conn_id=conn_id) assert hook.get_xcom_sidecar_container_resources() == expected + @pytest.mark.parametrize( + ("conn_id", "expected"), + ( + pytest.param( + "sidecar_container_security_context", + { + "allowPrivilegeEscalation": False, + "readOnlyRootFilesystem": True, + "seccompProfile": {"type": "RuntimeDefault"}, + }, + id="sidecar-with-security-context", + ), + pytest.param( + "sidecar_container_security_context_empty", None, id="sidecar-without-security-context" + ), + ), + ) + def test_get_xcom_sidecar_container_security_context(self, conn_id, expected): + hook = KubernetesHook(conn_id=conn_id) + assert hook.get_xcom_sidecar_container_security_context() == expected + @patch("kubernetes.config.kube_config.KubeConfigLoader") @patch("kubernetes.config.kube_config.KubeConfigMerger") def test_client_types(self, mock_kube_config_merger, mock_kube_config_loader): diff --git a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py index 11483b7f0a174..8163b95947b68 100644 --- a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py +++ b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py @@ -930,6 +930,60 @@ def test_xcom_sidecar_container_resources_custom(self): pod = k.build_pod_request_obj(create_context(k)) assert pod.spec.containers[1].resources == resources + def test_xcom_sidecar_container_security_context_default(self): + k = KubernetesPodOperator( + name="test", + task_id="task", + do_xcom_push=True, + ) + pod = k.build_pod_request_obj(create_context(k)) + assert pod.spec.containers[1].security_context is None + + @patch(f"{HOOK_CLASS}.get_xcom_sidecar_container_security_context") + def test_xcom_sidecar_container_security_context_from_connection(self, mock_get_security_context): + security_context = { + "allowPrivilegeEscalation": False, + "readOnlyRootFilesystem": True, + "seccompProfile": {"type": "RuntimeDefault"}, + } + mock_get_security_context.return_value = security_context + k = KubernetesPodOperator( + name="test", + task_id="task", + do_xcom_push=True, + ) + pod = k.build_pod_request_obj(create_context(k)) + assert pod.spec.containers[1].security_context == security_context + + @patch(f"{HOOK_CLASS}.get_xcom_sidecar_container_security_context") + def test_xcom_sidecar_container_security_context_operator_overrides_connection( + self, mock_get_security_context + ): + mock_get_security_context.return_value = {"readOnlyRootFilesystem": False} + operator_security_context = {"readOnlyRootFilesystem": True} + k = KubernetesPodOperator( + name="test", + task_id="task", + do_xcom_push=True, + xcom_sidecar_container_security_context=operator_security_context, + ) + pod = k.build_pod_request_obj(create_context(k)) + assert pod.spec.containers[1].security_context == operator_security_context + + @patch(f"{HOOK_CLASS}.get_xcom_sidecar_container_security_context") + def test_xcom_sidecar_container_security_context_empty_operator_overrides_connection( + self, mock_get_security_context + ): + mock_get_security_context.return_value = {"readOnlyRootFilesystem": True} + k = KubernetesPodOperator( + name="test", + task_id="task", + do_xcom_push=True, + xcom_sidecar_container_security_context={}, + ) + pod = k.build_pod_request_obj(create_context(k)) + assert pod.spec.containers[1].security_context == {} + def test_image_pull_policy_correctly_set(self): k = KubernetesPodOperator( task_id="task", @@ -1993,6 +2047,7 @@ def test_get_logs_but_not_for_base_container( ): hook_mock.return_value.get_xcom_sidecar_container_image.return_value = None hook_mock.return_value.get_xcom_sidecar_container_resources.return_value = None + hook_mock.return_value.get_xcom_sidecar_container_security_context.return_value = None k = KubernetesPodOperator( namespace="default", image="ubuntu:16.04", diff --git a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_xcom_sidecar.py b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_xcom_sidecar.py new file mode 100644 index 0000000000000..7fc9e9b4796ab --- /dev/null +++ b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_xcom_sidecar.py @@ -0,0 +1,71 @@ +# 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. +from __future__ import annotations + +import pytest +from kubernetes.client import models as k8s + +from airflow.providers.cncf.kubernetes.utils.xcom_sidecar import PodDefaults, add_xcom_sidecar + + +def _base_pod() -> k8s.V1Pod: + return k8s.V1Pod(spec=k8s.V1PodSpec(containers=[k8s.V1Container(name="base")])) + + +def _sidecar_of(pod: k8s.V1Pod) -> k8s.V1Container: + return next(c for c in pod.spec.containers if c.name == PodDefaults.SIDECAR_CONTAINER_NAME) + + +@pytest.mark.parametrize( + "security_context", + [ + pytest.param( + { + "allowPrivilegeEscalation": False, + "readOnlyRootFilesystem": True, + "seccompProfile": {"type": "RuntimeDefault"}, + }, + id="dict", + ), + pytest.param( + k8s.V1SecurityContext( + allow_privilege_escalation=False, + read_only_root_filesystem=True, + seccomp_profile=k8s.V1SeccompProfile(type="RuntimeDefault"), + ), + id="model", + ), + ], +) +def test_add_xcom_sidecar_sets_security_context(security_context): + pod = add_xcom_sidecar(_base_pod(), sidecar_container_security_context=security_context) + assert _sidecar_of(pod).security_context == security_context + + +def test_add_xcom_sidecar_without_security_context(): + pod = add_xcom_sidecar(_base_pod()) + assert _sidecar_of(pod).security_context is None + + +def test_add_xcom_sidecar_empty_security_context(): + pod = add_xcom_sidecar(_base_pod(), sidecar_container_security_context={}) + assert _sidecar_of(pod).security_context == {} + + +def test_add_xcom_sidecar_does_not_mutate_shared_default(): + add_xcom_sidecar(_base_pod(), sidecar_container_security_context={"readOnlyRootFilesystem": True}) + assert PodDefaults.SIDECAR_CONTAINER.security_context is None diff --git a/task-sdk/src/airflow/sdk/definitions/decorators/__init__.pyi b/task-sdk/src/airflow/sdk/definitions/decorators/__init__.pyi index 7b7c354a9a639..229a6037693cb 100644 --- a/task-sdk/src/airflow/sdk/definitions/decorators/__init__.pyi +++ b/task-sdk/src/airflow/sdk/definitions/decorators/__init__.pyi @@ -535,6 +535,7 @@ class TaskDecoratorCollection: tolerations: list[k8s.V1Toleration] | None = None, security_context: k8s.V1PodSecurityContext | dict | None = None, container_security_context: k8s.V1SecurityContext | dict | None = None, + xcom_sidecar_container_security_context: k8s.V1SecurityContext | dict | None = None, dnspolicy: str | None = None, dns_config: k8s.V1PodDNSConfig | None = None, hostname: str | None = None, @@ -624,6 +625,8 @@ class TaskDecoratorCollection: :param security_context: Security options the pod should run with (PodSecurityContext). :param container_security_context: security options the container should run with. + :param xcom_sidecar_container_security_context: security options the xcom sidecar container + should run with. Overrides the value configured on the Kubernetes connection. :param dnspolicy: DNS policy for the pod. :param dns_config: dns configuration (ip addresses, searches, options) for the pod. :param hostname: hostname for the pod. @@ -708,6 +711,7 @@ class TaskDecoratorCollection: tolerations: list[k8s.V1Toleration] | None = None, security_context: k8s.V1PodSecurityContext | dict | None = None, container_security_context: k8s.V1SecurityContext | dict | None = None, + xcom_sidecar_container_security_context: k8s.V1SecurityContext | dict | None = None, dnspolicy: str | None = None, dns_config: k8s.V1PodDNSConfig | None = None, hostname: str | None = None, @@ -794,6 +798,8 @@ class TaskDecoratorCollection: :param security_context: Security options the pod should run with (PodSecurityContext). :param container_security_context: security options the container should run with. + :param xcom_sidecar_container_security_context: security options the xcom sidecar container + should run with. Overrides the value configured on the Kubernetes connection. :param dnspolicy: DNS policy for the pod. :param dns_config: dns configuration (ip addresses, searches, options) for the pod. :param hostname: hostname for the pod. From dc7a45ded67d21b5c248a3b60d4f987d804cb0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Mirowski?= <17602603+Miretpl@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:51:43 +0200 Subject: [PATCH 14/86] Add missing newsfragment to Helm Chart (#70043) * Remove duplicated newsfragments * Add missing newsfragments * Consolidate Airflow version newsfragment --- chart/newsfragments/65666.significant.rst | 3 --- chart/newsfragments/65852.significant.rst | 1 + chart/newsfragments/66671.significant.rst | 3 +++ chart/newsfragments/67681.significant.rst | 3 --- chart/newsfragments/69481.significant.rst | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) delete mode 100644 chart/newsfragments/65666.significant.rst create mode 100644 chart/newsfragments/65852.significant.rst create mode 100644 chart/newsfragments/66671.significant.rst delete mode 100644 chart/newsfragments/67681.significant.rst diff --git a/chart/newsfragments/65666.significant.rst b/chart/newsfragments/65666.significant.rst deleted file mode 100644 index 0b56b8eff873d..0000000000000 --- a/chart/newsfragments/65666.significant.rst +++ /dev/null @@ -1,3 +0,0 @@ -Default Airflow image is updated to ``3.2.1`` - -The default Airflow image that is used with the Chart is now ``3.2.1``, previously it was ``3.2.0``. diff --git a/chart/newsfragments/65852.significant.rst b/chart/newsfragments/65852.significant.rst new file mode 100644 index 0000000000000..f461f9f6bc1ff --- /dev/null +++ b/chart/newsfragments/65852.significant.rst @@ -0,0 +1 @@ +Log Groomer in Celery workers is now only dependent on ``workers.celery.logGroomerSidecar.enabled`` flag. diff --git a/chart/newsfragments/66671.significant.rst b/chart/newsfragments/66671.significant.rst new file mode 100644 index 0000000000000..ca2ef78ac8577 --- /dev/null +++ b/chart/newsfragments/66671.significant.rst @@ -0,0 +1,3 @@ +All deprecated fields under ``workers`` section has been removed. + +Use ``workers.celery`` or ``workers.kubernetes`` instead. diff --git a/chart/newsfragments/67681.significant.rst b/chart/newsfragments/67681.significant.rst deleted file mode 100644 index 4ca878af0293e..0000000000000 --- a/chart/newsfragments/67681.significant.rst +++ /dev/null @@ -1,3 +0,0 @@ -Default Airflow image is updated to ``3.2.2`` - -The default Airflow image that is used with the Chart is now ``3.2.2``, previously it was ``3.2.1``. diff --git a/chart/newsfragments/69481.significant.rst b/chart/newsfragments/69481.significant.rst index e1fe52df5fd7b..1ccaf7c5b41d3 100644 --- a/chart/newsfragments/69481.significant.rst +++ b/chart/newsfragments/69481.significant.rst @@ -1,3 +1,3 @@ Default Airflow image is updated to ``3.3.0`` -The default Airflow image that is used with the Chart is now ``3.3.0``, previously it was ``3.2.2``. +The default Airflow image that is used with the Chart is now ``3.3.0``, previously it was ``3.2.0``. From cbd942ba17607b8aa51950e7bde503e48433f6d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Mirowski?= <17602603+Miretpl@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:52:42 +0200 Subject: [PATCH 15/86] Update doc (#70047) --- dev/README_RELEASE_AIRFLOW.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dev/README_RELEASE_AIRFLOW.md b/dev/README_RELEASE_AIRFLOW.md index 7e6005e75bba0..33881515df933 100644 --- a/dev/README_RELEASE_AIRFLOW.md +++ b/dev/README_RELEASE_AIRFLOW.md @@ -1631,10 +1631,13 @@ Update the values of `airflowVersion`, `defaultAirflowTag` and `appVersion` in t will use the latest released version. You'll need to update `chart/values.yaml`, `chart/values.schema.json` and `chart/Chart.yaml`. -Add or adjust significant `chart/newsfragments` to express that the default version of Airflow has changed. +Add or adjust already existing significant `chart/newsfragments` to express that the default version of Airflow +has changed. In `chart/Chart.yaml`, make sure the screenshot annotations are still all valid URLs. +Add `backport-to-chart/v1-2x-test` for automatic backport PR creation for Helm Chart 1.2x release line. Manual backport is required when automatic one will fail. + ## Update airflow/config_templates/config.yml file File `airflow/config_templates/config.yml` contains documentation on all configuration options available in Airflow. The `version_added` fields must be updated when a new Airflow version is released. From 880e4e0510226b31040772bdf0cde0a74107c614 Mon Sep 17 00:00:00 2001 From: Guan-Ming Chiu <105915352+guan404ming@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:03:26 +0900 Subject: [PATCH 16/86] Fix `@task.llm_branch` import failure on Task SDK-only workers (#70068) --- .../ai/src/airflow/providers/common/ai/decorators/llm_branch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/common/ai/src/airflow/providers/common/ai/decorators/llm_branch.py b/providers/common/ai/src/airflow/providers/common/ai/decorators/llm_branch.py index 91b7d7640d148..664036ae1350d 100644 --- a/providers/common/ai/src/airflow/providers/common/ai/decorators/llm_branch.py +++ b/providers/common/ai/src/airflow/providers/common/ai/decorators/llm_branch.py @@ -33,10 +33,10 @@ DecoratedOperator, TaskDecorator, context_merge, + determine_kwargs, task_decorator_factory, ) from airflow.sdk.definitions._internal.types import SET_DURING_EXECUTION -from airflow.utils.operator_helpers import determine_kwargs if TYPE_CHECKING: from airflow.sdk import Context From f3c58395e1155b49f68527c686b59d9b37718154 Mon Sep 17 00:00:00 2001 From: Guan-Ming Chiu <105915352+guan404ming@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:04:04 +0900 Subject: [PATCH 17/86] Reject unsupported require_approval in LLM branch and schema compare operators (#70069) --- .../airflow/providers/common/ai/operators/llm_branch.py | 2 ++ .../providers/common/ai/operators/llm_schema_compare.py | 2 ++ .../ai/tests/unit/common/ai/operators/test_llm_branch.py | 9 +++++++++ .../unit/common/ai/operators/test_llm_schema_compare.py | 5 +++++ 4 files changed, 18 insertions(+) diff --git a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py index 0395040852f53..b6a25a7811eb0 100644 --- a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py +++ b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_branch.py @@ -60,6 +60,8 @@ def __init__( **kwargs: Any, ) -> None: kwargs.pop("output_type", None) + if kwargs.get("require_approval"): + raise ValueError("require_approval=True is not supported by LLMBranchOperator.") super().__init__(**kwargs) self.allow_multiple_branches = allow_multiple_branches diff --git a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py index b51ea7c1f7c3b..d65ca521876b8 100644 --- a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py +++ b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_schema_compare.py @@ -127,6 +127,8 @@ def __init__( **kwargs: Any, ) -> None: kwargs.pop("output_type", None) + if kwargs.get("require_approval"): + raise ValueError("require_approval=True is not supported by LLMSchemaCompareOperator.") super().__init__(**kwargs) self.data_sources = data_sources or [] self.db_conn_ids = db_conn_ids or [] diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py b/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py index 390137e9fc9be..fccf5e2f83737 100644 --- a/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py +++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm_branch.py @@ -54,6 +54,15 @@ def test_output_type_ignored(self): # the real output_type is built dynamically from downstream_task_ids assert op.output_type is str + def test_require_approval_rejected(self): + with pytest.raises(ValueError, match="require_approval=True is not supported"): + LLMBranchOperator( + task_id="test", + prompt="pick a branch", + llm_conn_id="my_llm", + require_approval=True, + ) + @patch.object(LLMBranchOperator, "do_branch") @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook", autospec=True) def test_execute_single_branch(self, mock_hook_cls, mock_do_branch): diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py b/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py index c7a23de583847..01fee1950d130 100644 --- a/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py +++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm_schema_compare.py @@ -97,6 +97,11 @@ class TestLLMSchemaCompareOperator: "at-least two combinations", id="one_datasource_only", ), + pytest.param( + {"require_approval": True}, + "require_approval=True is not supported", + id="require_approval", + ), ], ) def test_init_validation(self, kwargs, expected_error): From d08179572c67721d3c3aa17ee6d0a2e2b2332765 Mon Sep 17 00:00:00 2001 From: Amitesh Gupta <143833521+singlaamitesh@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:52:54 +0530 Subject: [PATCH 18/86] Fix stale docs.sqlalchemy.org/en/14 pooling link in config.yml (#69751) From 89da2eca1a11b49891f0fcfb8e4434652d0c73a5 Mon Sep 17 00:00:00 2001 From: PoAn Yang Date: Sat, 18 Jul 2026 21:05:29 +0900 Subject: [PATCH 19/86] Java SDK: Throw MissingXComException instead of NPE for absent XComs (#69257) Signed-off-by: PoAn Yang --- .../language-sdks/java.rst | 8 ++ .../apache/airflow/sdk/BuilderProcessor.kt | 25 ++++-- .../org/apache/airflow/sdk/BuilderTest.kt | 83 +++++++++++++++++-- .../kotlin/org/apache/airflow/sdk/Client.kt | 16 ++++ .../org/apache/airflow/sdk/ClientTest.kt | 12 +++ 5 files changed, 131 insertions(+), 13 deletions(-) diff --git a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst index e72160f77afb2..c8b73b4626a28 100644 --- a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst +++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst @@ -426,6 +426,14 @@ represented as Java objects when read back via ``getXCom``. - object - ``Map`` +.. note:: + + An ``@Builder.XCom`` parameter that reads a value which was never pushed resolves to + ``null``. A boxed parameter (``Integer``, ``Long``, ``Boolean``, …) receives ``null`` + safely, but a primitive parameter (``int``, ``long``, ``boolean``, …) cannot represent + ``null`` and the task fails with ``MissingXComException``. Declare the parameter with a + boxed type when the upstream XCom may be absent. + .. _java-sdk/build: Building and packaging diff --git a/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt b/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt index b764c00a9941c..11202ffcdb455 100644 --- a/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt +++ b/java-sdk/processor/src/main/kotlin/org/apache/airflow/sdk/BuilderProcessor.kt @@ -216,21 +216,36 @@ private val NUMBER_ACCESSORS: Map = } private fun xcomAccess(xcom: RequiredXCom): CodeBlock { - val call = CodeBlock.of($$"client.getXCom($S)", xcom.taskId) val type = TypeName.get(xcom.paramType) val accessor = NUMBER_ACCESSORS[type] val number = ClassName.get(Number::class.java) + val optional = ClassName.get(Optional::class.java) + // A primitive parameter cannot hold null, so fail with a clear error instead of an + // opaque NullPointerException while unboxing when the XCom is absent. + val value = + if (type.isPrimitive) { + CodeBlock.of( + $$"$T.ofNullable(client.getXCom($S)).orElseThrow(() -> new $T($S, $S))", + optional, + xcom.taskId, + ClassName.get(MissingXComException::class.java), + xcom.taskId, + xcom.paramName, + ) + } else { + CodeBlock.of($$"client.getXCom($S)", xcom.taskId) + } // Wire integers decode to Long and floats to Double, so a direct (Integer)/(Float) // cast throws ClassCastException; widen via Number instead. return when { - accessor == null -> CodeBlock.of($$"($T) $L", if (type.isPrimitive) type.box() else type, call) - type.isPrimitive -> CodeBlock.of($$"(($T) $L).$L()", number, call, accessor) + accessor == null -> CodeBlock.of($$"($T) $L", if (type.isPrimitive) type.box() else type, value) + type.isPrimitive -> CodeBlock.of($$"(($T) $L).$L()", number, value, accessor) else -> CodeBlock.of( $$"$T.ofNullable(($T) $L).map($T::$L).orElse(null)", - ClassName.get(Optional::class.java), + optional, number, - call, + value, number, accessor, ) diff --git a/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt b/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt index 692374d9e3cb5..3e28e1b009afd 100644 --- a/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt +++ b/java-sdk/processor/src/test/kotlin/org/apache/airflow/sdk/BuilderTest.kt @@ -80,9 +80,11 @@ class BuilderTest { import java.lang.Exception; import java.lang.Number; import java.lang.Override; + import java.util.Optional; import org.apache.airflow.sdk.Client; import org.apache.airflow.sdk.Context; import org.apache.airflow.sdk.Dag; + import org.apache.airflow.sdk.MissingXComException; import org.apache.airflow.sdk.Task; public final class TestExampleBuilder { @@ -108,7 +110,7 @@ class BuilderTest { public static final class T3 implements Task { @Override public void execute(Context context, Client client) throws Exception { - var value = ((Number) client.getXCom("t2")).intValue(); + var value = ((Number) Optional.ofNullable(client.getXCom("t2")).orElseThrow(() -> new MissingXComException("t2", "value"))).intValue(); new TestExample().t3(context, value); } } @@ -133,7 +135,10 @@ class BuilderTest { @Builder.XCom(task = "b") long l, @Builder.XCom(task = "c") double d, @Builder.XCom(task = "f") float fl, - @Builder.XCom(task = "e") Integer boxed) {} + @Builder.XCom(task = "e") Integer boxedInteger, + @Builder.XCom(task = "g") Long boxedLong, + @Builder.XCom(task = "h") Double boxedDouble, + @Builder.XCom(task = "j") Float boxedFloat) {} } """, ) @@ -153,6 +158,70 @@ class BuilderTest { import org.apache.airflow.sdk.Client; import org.apache.airflow.sdk.Context; import org.apache.airflow.sdk.Dag; + import org.apache.airflow.sdk.MissingXComException; + import org.apache.airflow.sdk.Task; + + public final class TestExampleBuilder { + public static Dag build() { + var dag = new Dag("TestExample"); + dag.addTask("t", T.class); + return dag; + } + public static final class T implements Task { + @Override + public void execute(Context context, Client client) throws Exception { + var i = ((Number) Optional.ofNullable(client.getXCom("a")).orElseThrow(() -> new MissingXComException("a", "i"))).intValue(); + var l = ((Number) Optional.ofNullable(client.getXCom("b")).orElseThrow(() -> new MissingXComException("b", "l"))).longValue(); + var d = ((Number) Optional.ofNullable(client.getXCom("c")).orElseThrow(() -> new MissingXComException("c", "d"))).doubleValue(); + var fl = ((Number) Optional.ofNullable(client.getXCom("f")).orElseThrow(() -> new MissingXComException("f", "fl"))).floatValue(); + var boxedInteger = Optional.ofNullable((Number) client.getXCom("e")).map(Number::intValue).orElse(null); + var boxedLong = Optional.ofNullable((Number) client.getXCom("g")).map(Number::longValue).orElse(null); + var boxedDouble = Optional.ofNullable((Number) client.getXCom("h")).map(Number::doubleValue).orElse(null); + var boxedFloat = Optional.ofNullable((Number) client.getXCom("j")).map(Number::floatValue).orElse(null); + new TestExample().t(i, l, d, fl, boxedInteger, boxedLong, boxedDouble, boxedFloat); + } + } + } + """, + ) + } + + @Test + @DisplayName("guard non-numeric primitives, leave objects and boxed types nullable") + fun generateBuilderGuardsNonNumericPrimitiveXCom() { + val compilation = + compile( + """ + package org.apache.airflow.example; + import org.apache.airflow.sdk.Builder; + @Builder.Dag + public class TestExample { + @Builder.Task + public void t( + @Builder.XCom(task = "a") boolean flag, + @Builder.XCom(task = "b") String text, + @Builder.XCom(task = "c") Boolean boxed) {} + } + """, + ) + + assertThat(compilation).succeeded() + assertThat(compilation) + .generatedSourceFile("org.apache.airflow.example.TestExampleBuilder") + .hasSourceEquivalentTo( + "org.apache.airflow.example.TestExampleBuilder", + """ + package org.apache.airflow.example; + + import java.lang.Boolean; + import java.lang.Exception; + import java.lang.Override; + import java.lang.String; + import java.util.Optional; + import org.apache.airflow.sdk.Client; + import org.apache.airflow.sdk.Context; + import org.apache.airflow.sdk.Dag; + import org.apache.airflow.sdk.MissingXComException; import org.apache.airflow.sdk.Task; public final class TestExampleBuilder { @@ -164,12 +233,10 @@ class BuilderTest { public static final class T implements Task { @Override public void execute(Context context, Client client) throws Exception { - var i = ((Number) client.getXCom("a")).intValue(); - var l = ((Number) client.getXCom("b")).longValue(); - var d = ((Number) client.getXCom("c")).doubleValue(); - var fl = ((Number) client.getXCom("f")).floatValue(); - var boxed = Optional.ofNullable((Number) client.getXCom("e")).map(Number::intValue).orElse(null); - new TestExample().t(i, l, d, fl, boxed); + var flag = (Boolean) Optional.ofNullable(client.getXCom("a")).orElseThrow(() -> new MissingXComException("a", "flag")); + var text = (String) client.getXCom("b"); + var boxed = (Boolean) client.getXCom("c"); + new TestExample().t(flag, text, boxed); } } } diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Client.kt b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Client.kt index 888b6d45bf6d3..59aa7832a3756 100644 --- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Client.kt +++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Client.kt @@ -153,3 +153,19 @@ class Client internal constructor( mapIndex = details.ti.mapIndex ?: -1, ) } + +/** + * Thrown when a task parameter with a primitive type reads an XCom that was never pushed. + */ +class MissingXComException( + message: String, +) : IllegalStateException(message) { + constructor( + taskId: String, + paramName: String, + ) : this( + "Task parameter '$paramName' requires an XCom from task '$taskId', but none was pushed. " + + "This parameter has a primitive type that cannot be null; declare it with a boxed type " + + "(e.g. Integer instead of int) to receive null.", + ) +} diff --git a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ClientTest.kt b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ClientTest.kt index b0646bc691fad..6428ec224eed2 100644 --- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ClientTest.kt +++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/ClientTest.kt @@ -89,4 +89,16 @@ class ClientTest { Assertions.assertNull(connection.port) } + + @Test + @DisplayName("MissingXComException builds the full message naming the task and parameter") + fun missingXComExceptionBuildsFullMessage() { + val ex = MissingXComException("produce", "value") + Assertions.assertEquals( + "Task parameter 'value' requires an XCom from task 'produce', but none was pushed. " + + "This parameter has a primitive type that cannot be null; declare it with a boxed type " + + "(e.g. Integer instead of int) to receive null.", + ex.message, + ) + } } From ffa04b010b579d8ca938368e7c8f3fcad7826e6d Mon Sep 17 00:00:00 2001 From: Christos Bisias Date: Sat, 18 Jul 2026 15:06:44 +0300 Subject: [PATCH 20/86] Rename Kafka Listener to Kafka Event Producer and update the documentation (#70014) --- .../core_api/routes/public/test_plugins.py | 2 +- .../apache/kafka/docs/configurations-ref.rst | 42 +++-- providers/apache/kafka/provider.yaml | 16 +- providers/apache/kafka/pyproject.toml | 2 +- .../apache/kafka/get_provider_info.py | 12 +- .../{listener.py => event_producer.py} | 33 ++-- ...est_listener.py => test_event_producer.py} | 14 +- ...est_listener.py => test_event_producer.py} | 176 +++++++++--------- .../ci/docker-compose/integration-kafka.yml | 8 +- 9 files changed, 161 insertions(+), 144 deletions(-) rename providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/{listener.py => event_producer.py} (92%) rename providers/apache/kafka/tests/integration/apache/kafka/plugins/{test_listener.py => test_event_producer.py} (92%) rename providers/apache/kafka/tests/unit/apache/kafka/plugins/{test_listener.py => test_event_producer.py} (72%) diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py index 1d9ba2175fab7..38b2cc8fa9faf 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py @@ -50,7 +50,7 @@ class TestGetPlugins: "edge_executor", "hitl_review", "hive", - "kafka_listener", + "kafka_event_producer", "plugin-a", "plugin-b", "plugin-c", diff --git a/providers/apache/kafka/docs/configurations-ref.rst b/providers/apache/kafka/docs/configurations-ref.rst index 80d0d373f8f2a..de52624721a6c 100644 --- a/providers/apache/kafka/docs/configurations-ref.rst +++ b/providers/apache/kafka/docs/configurations-ref.rst @@ -24,27 +24,41 @@ Highlighted configurations =========================== -The ``[kafka_listener]`` section configures the ``KafkaListenerPlugin``, +The ``[kafka_event_producer]`` section configures the ``KafkaEventProducerPlugin``, which publishes Airflow DagRun and TaskInstance state-change events to a Kafka topic. DagRun and TaskInstance events are separated and enabled by distinct flags. Both event-type flags default to ``False``. -.. _configuration_kafka_listener_activation:kafka: +.. _configuration_kafka_event_producer_use_cases:kafka: -Activating the listener ------------------------ +Common use-cases +---------------- + + * Consume the Kafka events by an external observability or analytics tool and gather info about the state + of multiple Airflow instances without polling their metadata DBs. + * Based on the state of a DagRun, trigger a downstream external system/pipeline (notifications, alerting, + cross-team handoffs) without direct interaction with Airflow. + * Coordinate Dags across multiple Airflow instances over a shared Kafka service. + + * For example, team_A with Airflow instance_A has a deferred task which is triggered + when a task from team_B with Airflow instance_B finishes. + +.. _configuration_kafka_event_producer_activation:kafka: + +Activating the plugin +--------------------- To enable event publishing you need to * enable at least one event-type flag - * point the listener at an Airflow Kafka connection via ``kafka_config_id`` + * point the plugin at an Airflow Kafka connection via ``kafka_config_id`` (defaults to ``kafka_default``) that carries the broker address and any other confluent-kafka client options on its extras * have a pre-existing kafka topic .. code-block:: ini - [kafka_listener] + [kafka_event_producer] dag_run_events_enabled = True task_instance_events_enabled = True kafka_config_id = kafka_events @@ -68,20 +82,20 @@ Environment-variable equivalents: .. code-block:: ini - AIRFLOW__KAFKA_LISTENER__DAG_RUN_EVENTS_ENABLED=True - AIRFLOW__KAFKA_LISTENER__TASK_INSTANCE_EVENTS_ENABLED=True - AIRFLOW__KAFKA_LISTENER__KAFKA_CONFIG_ID=kafka_events - AIRFLOW__KAFKA_LISTENER__TOPIC=airflow.events + AIRFLOW__KAFKA_EVENT_PRODUCER__DAG_RUN_EVENTS_ENABLED=True + AIRFLOW__KAFKA_EVENT_PRODUCER__TASK_INSTANCE_EVENTS_ENABLED=True + AIRFLOW__KAFKA_EVENT_PRODUCER__KAFKA_CONFIG_ID=kafka_events + AIRFLOW__KAFKA_EVENT_PRODUCER__TOPIC=airflow.events The two event flags are independent, users can opt-in to get only DagRun event messages or only TaskInstance event messages or both. The topic must already exist on the broker, it's not auto-created. On a missing -topic, broker connection failure, or any other producer init error, the listener +topic, broker connection failure, or any other producer init error, the plugin doesn't fail, instead it logs a warning and retries the init after ``topic_check_retry_interval`` -seconds (default ``60``). Once the topic is created on the broker the listener will pick it up. +seconds (default ``60``). Once the topic is created on the broker the plugin will pick it up. -.. _configuration_kafka_listener_filtering:kafka: +.. _configuration_kafka_event_producer_filtering:kafka: Filtering events ---------------- @@ -92,7 +106,7 @@ comma-separated list of ``fnmatch`` glob patterns; an empty list means .. code-block:: ini - [kafka_listener] + [kafka_event_producer] dag_run_dag_id_allowlist = sales_*,marketing_* dag_run_dag_id_denylist = sales_internal_* diff --git a/providers/apache/kafka/provider.yaml b/providers/apache/kafka/provider.yaml index 8e23df573095b..a0ae3a8aebd15 100644 --- a/providers/apache/kafka/provider.yaml +++ b/providers/apache/kafka/provider.yaml @@ -131,14 +131,14 @@ queues: - airflow.providers.apache.kafka.queues.kafka.KafkaMessageQueueProvider plugins: - - name: kafka_listener - plugin-class: airflow.providers.apache.kafka.plugins.listener.KafkaListenerPlugin + - name: kafka_event_producer + plugin-class: airflow.providers.apache.kafka.plugins.event_producer.KafkaEventProducerPlugin config: - kafka_listener: + kafka_event_producer: description: | - Settings for the Kafka listener that publishes Airflow DagRun and - TaskInstance state-change events to a Kafka topic. + Settings for the Kafka event producer plugin that publishes Airflow + DagRun and TaskInstance state-change events to a Kafka topic. options: dag_run_events_enabled: description: | @@ -161,7 +161,7 @@ config: default: "False" kafka_config_id: description: | - Airflow connection used to build the listener's Kafka producer. + Airflow connection used to build the plugin's Kafka producer. When unset, the producer hook falls back to its default connection (``kafka_default``). version_added: 1.14.1 @@ -170,8 +170,8 @@ config: default: "" topic: description: | - Topic the listener publishes events to. The topic must already - exist on the broker; the listener will not auto-create it. + Topic the plugin publishes events to. The topic must already + exist on the broker; the plugin will not auto-create it. version_added: 1.14.1 type: string example: ~ diff --git a/providers/apache/kafka/pyproject.toml b/providers/apache/kafka/pyproject.toml index 3c5ead9a4b80c..b2e3a1fa59880 100644 --- a/providers/apache/kafka/pyproject.toml +++ b/providers/apache/kafka/pyproject.toml @@ -131,7 +131,7 @@ apache-airflow-providers-standard = {workspace = true} provider_info = "airflow.providers.apache.kafka.get_provider_info:get_provider_info" [project.entry-points."airflow.plugins"] -kafka_listener = "airflow.providers.apache.kafka.plugins.listener:KafkaListenerPlugin" +kafka_event_producer = "airflow.providers.apache.kafka.plugins.event_producer:KafkaEventProducerPlugin" [tool.flit.module] name = "airflow.providers.apache.kafka" diff --git a/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py b/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py index d8c8510cb5ddb..31162c55425e1 100644 --- a/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py +++ b/providers/apache/kafka/src/airflow/providers/apache/kafka/get_provider_info.py @@ -103,13 +103,13 @@ def get_provider_info(): "queues": ["airflow.providers.apache.kafka.queues.kafka.KafkaMessageQueueProvider"], "plugins": [ { - "name": "kafka_listener", - "plugin-class": "airflow.providers.apache.kafka.plugins.listener.KafkaListenerPlugin", + "name": "kafka_event_producer", + "plugin-class": "airflow.providers.apache.kafka.plugins.event_producer.KafkaEventProducerPlugin", } ], "config": { - "kafka_listener": { - "description": "Settings for the Kafka listener that publishes Airflow DagRun and\nTaskInstance state-change events to a Kafka topic.\n", + "kafka_event_producer": { + "description": "Settings for the Kafka event producer plugin that publishes Airflow\nDagRun and TaskInstance state-change events to a Kafka topic.\n", "options": { "dag_run_events_enabled": { "description": "Publish DagRun state-change events (``dag_run.running``,\n``dag_run.success``, ``dag_run.failed``). When False the\nDagRun listener is not registered.\n", @@ -126,14 +126,14 @@ def get_provider_info(): "default": "False", }, "kafka_config_id": { - "description": "Airflow connection used to build the listener's Kafka producer.\nWhen unset, the producer hook falls back to its default\nconnection (``kafka_default``).\n", + "description": "Airflow connection used to build the plugin's Kafka producer.\nWhen unset, the producer hook falls back to its default\nconnection (``kafka_default``).\n", "version_added": "1.14.1", "type": "string", "example": "kafka_default", "default": "", }, "topic": { - "description": "Topic the listener publishes events to. The topic must already\nexist on the broker; the listener will not auto-create it.\n", + "description": "Topic the plugin publishes events to. The topic must already\nexist on the broker; the plugin will not auto-create it.\n", "version_added": "1.14.1", "type": "string", "example": None, diff --git a/providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/listener.py b/providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/event_producer.py similarity index 92% rename from providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/listener.py rename to providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/event_producer.py index 342b37139a094..20f6f1f6e449e 100644 --- a/providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/listener.py +++ b/providers/apache/kafka/src/airflow/providers/apache/kafka/plugins/event_producer.py @@ -40,7 +40,7 @@ log = logging.getLogger(__name__) -CONFIG_SECTION = "kafka_listener" +CONFIG_SECTION = "kafka_event_producer" SCHEMA_VERSION = 1 @@ -152,7 +152,7 @@ def _task_instance_event_allowed(dag_id: str, task_id: str) -> bool: ) -# Listener-owned producer and topic state for the lifetime of the process. +# Plugin-owned producer and topic state for the lifetime of the process. # The producer is built once via KafkaProducerHook and kept for the process lifetime; # the topic flags track whether the topic exists on the broker and whether we're # currently in a back-off window after a failed topic check. @@ -179,7 +179,7 @@ def _reset_state_after_fork() -> None: def _get_producer() -> Producer | None: - """Build (once) and return the listener's producer, or ``None`` on init failure.""" + """Build (once) and return the plugin's Kafka producer, or ``None`` on init failure.""" global _producer if _producer is not None: return _producer @@ -194,7 +194,7 @@ def _get_producer() -> Producer | None: hook_kwargs["kafka_config_id"] = _get_kafka_config_id() producer = KafkaProducerHook(**hook_kwargs).get_producer() except Exception as exc: - log.warning("Kafka listener: failed to initialize producer (%s).", exc) + log.warning("Kafka event producer: failed to initialize producer (%s).", exc) return None atexit.register(_flush_producer_at_exit, producer) @@ -224,7 +224,7 @@ def _check_topic_exists() -> bool: topics = producer.list_topics(timeout=_get_topic_check_timeout()).topics except Exception as exc: log.warning( - "Kafka listener: topic check failed (%s). Will retry after %ds.", + "Kafka event producer: topic check failed (%s). Will retry after %ds.", exc, _get_topic_check_retry_interval(), ) @@ -233,7 +233,7 @@ def _check_topic_exists() -> bool: if _get_topic() not in topics: log.warning( - "Kafka listener: topic %r not found on the broker. Will retry after %ds. " + "Kafka event producer: topic %r not found on the broker. Will retry after %ds. " "Create the topic on the broker to enable publishing.", _get_topic(), _get_topic_check_retry_interval(), @@ -242,7 +242,7 @@ def _check_topic_exists() -> bool: return False log.info( - "Kafka listener attached: pid=%s source=%r topic=%r", + "Kafka event producer attached: pid=%s source=%r topic=%r", os.getpid(), _get_source(), _get_topic(), @@ -255,16 +255,16 @@ def _flush_producer_at_exit(producer: Producer) -> None: try: producer.flush(5) except Exception: - log.debug("Kafka listener: error flushing producer on exit", exc_info=True) + log.debug("Kafka event producer: error flushing producer on exit", exc_info=True) def _on_delivery(err, _msg) -> None: if err is None: return - log.warning("Kafka listener: delivery failed: %s", err) + log.warning("Kafka event producer: delivery failed: %s", err) # If the broker or local metadata says the topic is gone, the confirmation cached at # attach-time is stale. Flip it back so the next _check_topic_exists re-verifies with - # the broker, gated by the same cooldown used elsewhere. Lets the listener auto-recover + # the broker, gated by the same cooldown used elsewhere. Lets the plugin auto-recover # if the topic gets recreated instead of requiring a component restart. from confluent_kafka import KafkaError @@ -291,7 +291,7 @@ def _produce_dr_message(event: str, dag_run: DagRun, msg: str) -> None: _get_dr_payload(dag_run, msg), ) except Exception: - log.exception("Kafka listener: %s failed", event) + log.exception("Kafka event producer: %s failed", event) def _get_dr_payload(dag_run, msg) -> dict[str, Any]: @@ -325,7 +325,7 @@ def _produce_ti_message( _get_ti_payload(task_instance, previous_state, error=error), ) except Exception: - log.exception("Kafka listener: %s failed", event) + log.exception("Kafka event producer: %s failed", event) def _get_ti_payload(ti, previous_state, error=None) -> dict[str, Any]: @@ -373,9 +373,12 @@ def _produce_message(event: str, dag_id: str, run_id: str, payload: dict[str, An ) producer.poll(0) except Exception as ex: - log.warning("Kafka listener: failed to enqueue %s for %s/%s: %s", event, dag_id, run_id, ex) + log.warning("Kafka event producer: failed to enqueue %s for %s/%s: %s", event, dag_id, run_id, ex) +# DagRunListener / TaskListener are pluggy hookimpl classes that plug into Airflow's +# listener API (``AirflowPlugin.listeners``). These classes listen for Airflow events +# and then produce a message for every event. class DagRunListener: """Publishes DagRun state-change event messages to Kafka.""" @@ -463,8 +466,8 @@ def _get_enabled_listeners() -> list[object]: return listeners -class KafkaListenerPlugin(AirflowPlugin): +class KafkaEventProducerPlugin(AirflowPlugin): """Publishes Airflow DagRun and TaskInstance event messages to a defined Kafka topic.""" - name = "kafka_listener" + name = "kafka_event_producer" listeners = _get_enabled_listeners() diff --git a/providers/apache/kafka/tests/integration/apache/kafka/plugins/test_listener.py b/providers/apache/kafka/tests/integration/apache/kafka/plugins/test_event_producer.py similarity index 92% rename from providers/apache/kafka/tests/integration/apache/kafka/plugins/test_listener.py rename to providers/apache/kafka/tests/integration/apache/kafka/plugins/test_event_producer.py index ce900f79b8734..81cbc4ca931e3 100644 --- a/providers/apache/kafka/tests/integration/apache/kafka/plugins/test_listener.py +++ b/providers/apache/kafka/tests/integration/apache/kafka/plugins/test_event_producer.py @@ -42,7 +42,7 @@ client_config = { "bootstrap.servers": "broker:29092", - "group.id": "kafka-listener-integration-test", + "group.id": "kafka-event-producer-integration-test", "enable.auto.commit": False, "auto.offset.reset": "earliest", } @@ -59,7 +59,7 @@ def _wait_for_assignment(consumer, timeout: float = 10.0) -> None: @pytest.mark.integration("kafka") @pytest.mark.backend("postgres") -class TestEventListener: +class TestEventProducer: test_dir = os.path.dirname(os.path.abspath(__file__)) dag_folder = os.path.join(test_dir, "dags") @@ -83,12 +83,12 @@ def setup_class(cls): os.environ["AIRFLOW__CORE__LOAD_EXAMPLES"] = "False" os.environ["AIRFLOW__CORE__UNIT_TEST_MODE"] = "False" - os.environ["AIRFLOW__KAFKA_LISTENER__DAG_RUN_EVENTS_ENABLED"] = "True" - os.environ["AIRFLOW__KAFKA_LISTENER__TASK_INSTANCE_EVENTS_ENABLED"] = "True" - os.environ["AIRFLOW__KAFKA_LISTENER__TOPIC"] = cls.TOPIC - os.environ["AIRFLOW__KAFKA_LISTENER__SOURCE"] = "dev-breeze" + os.environ["AIRFLOW__KAFKA_EVENT_PRODUCER__DAG_RUN_EVENTS_ENABLED"] = "True" + os.environ["AIRFLOW__KAFKA_EVENT_PRODUCER__TASK_INSTANCE_EVENTS_ENABLED"] = "True" + os.environ["AIRFLOW__KAFKA_EVENT_PRODUCER__TOPIC"] = cls.TOPIC + os.environ["AIRFLOW__KAFKA_EVENT_PRODUCER__SOURCE"] = "dev-breeze" - # Shared Kafka connection: used by the listener producer, the topic + # Shared Kafka connection: used by the event producer plugin, the topic # creation/deletion, and the consumer. kafka_default_conn = Connection( conn_id=cls.KAFKA_CONFIG_ID, diff --git a/providers/apache/kafka/tests/unit/apache/kafka/plugins/test_listener.py b/providers/apache/kafka/tests/unit/apache/kafka/plugins/test_event_producer.py similarity index 72% rename from providers/apache/kafka/tests/unit/apache/kafka/plugins/test_listener.py rename to providers/apache/kafka/tests/unit/apache/kafka/plugins/test_event_producer.py index 38bdc0566372f..8e4ed0fcf4ee3 100644 --- a/providers/apache/kafka/tests/unit/apache/kafka/plugins/test_listener.py +++ b/providers/apache/kafka/tests/unit/apache/kafka/plugins/test_event_producer.py @@ -24,8 +24,8 @@ from confluent_kafka import KafkaError from airflow.models import Connection -from airflow.providers.apache.kafka.plugins import listener -from airflow.providers.apache.kafka.plugins.listener import DagRunListener, TaskListener +from airflow.providers.apache.kafka.plugins import event_producer +from airflow.providers.apache.kafka.plugins.event_producer import DagRunListener, TaskListener from tests_common.test_utils.config import conf_vars from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS @@ -48,7 +48,7 @@ @pytest.fixture(autouse=True) def _default_kafka_conn(create_connection_without_db): - # The listener builds its producer through ``KafkaProducerHook``, which requires + # The plugin builds its producer through ``KafkaProducerHook``, which requires # a resolvable ``kafka_default`` connection. create_connection_without_db( Connection( @@ -63,24 +63,24 @@ def _default_kafka_conn(create_connection_without_db): def _clear_cached_values(): """Invalidates caches before each test run.""" # Reset config cache. - listener._dag_run_events_enabled.cache_clear() - listener._task_instance_events_enabled.cache_clear() - listener._get_topic.cache_clear() - listener._get_kafka_config_id.cache_clear() - listener._get_source.cache_clear() - listener._get_dag_run_dag_id_allowlist.cache_clear() - listener._get_dag_run_dag_id_denylist.cache_clear() - listener._get_task_instance_dag_id_allowlist.cache_clear() - listener._get_task_instance_dag_id_denylist.cache_clear() - listener._get_task_instance_task_id_allowlist.cache_clear() - listener._get_task_instance_task_id_denylist.cache_clear() - listener._get_topic_check_timeout.cache_clear() - listener._get_topic_check_retry_interval.cache_clear() + event_producer._dag_run_events_enabled.cache_clear() + event_producer._task_instance_events_enabled.cache_clear() + event_producer._get_topic.cache_clear() + event_producer._get_kafka_config_id.cache_clear() + event_producer._get_source.cache_clear() + event_producer._get_dag_run_dag_id_allowlist.cache_clear() + event_producer._get_dag_run_dag_id_denylist.cache_clear() + event_producer._get_task_instance_dag_id_allowlist.cache_clear() + event_producer._get_task_instance_dag_id_denylist.cache_clear() + event_producer._get_task_instance_task_id_allowlist.cache_clear() + event_producer._get_task_instance_task_id_denylist.cache_clear() + event_producer._get_topic_check_timeout.cache_clear() + event_producer._get_topic_check_retry_interval.cache_clear() # Reset the module-level producer and topic state. - listener._producer = None - listener._topic_exists = False - listener._topic_check_retry_after = 0.0 + event_producer._producer = None + event_producer._topic_exists = False + event_producer._topic_check_retry_after = 0.0 @pytest.fixture @@ -126,7 +126,7 @@ def _assert_common_message_fields(kafka_producer_mock, expected_event: str) -> d assert kwargs["key"] == f"{_DAG_ID}/{_DAG_RUN_ID}".encode() body = json.loads(kwargs["value"].decode("utf-8")) - assert body["schema_version"] == listener.SCHEMA_VERSION + assert body["schema_version"] == event_producer.SCHEMA_VERSION assert body["source"] == _SOURCE assert body["event"] == expected_event assert body["dag_id"] == _DAG_ID @@ -140,22 +140,22 @@ def _assert_common_message_fields(kafka_producer_mock, expected_event: str) -> d [ pytest.param( { - ("kafka_listener", "dag_run_events_enabled"): "True", - ("kafka_listener", "task_instance_events_enabled"): "True", + ("kafka_event_producer", "dag_run_events_enabled"): "True", + ("kafka_event_producer", "task_instance_events_enabled"): "True", }, [DagRunListener, TaskListener], id="both_listeners_enabled", ), pytest.param( { - ("kafka_listener", "dag_run_events_enabled"): "True", + ("kafka_event_producer", "dag_run_events_enabled"): "True", }, [DagRunListener], id="only_dr_events", ), pytest.param( { - ("kafka_listener", "task_instance_events_enabled"): "True", + ("kafka_event_producer", "task_instance_events_enabled"): "True", }, [TaskListener], id="only_ti_events", @@ -169,7 +169,7 @@ def _assert_common_message_fields(kafka_producer_mock, expected_event: str) -> d ) def test_get_enabled_listeners(configs, expected_listener_classes): with conf_vars(configs): - registered_listeners = listener._get_enabled_listeners() + registered_listeners = event_producer._get_enabled_listeners() # Both lists should have the same size. assert len(registered_listeners) == len(expected_listener_classes) @@ -187,9 +187,9 @@ def test_get_enabled_listeners(configs, expected_listener_classes): ) @conf_vars( { - ("kafka_listener", "dag_run_events_enabled"): "True", - ("kafka_listener", "topic"): _KAFKA_TOPIC, - ("kafka_listener", "source"): _SOURCE, + ("kafka_event_producer", "dag_run_events_enabled"): "True", + ("kafka_event_producer", "topic"): _KAFKA_TOPIC, + ("kafka_event_producer", "source"): _SOURCE, } ) def test_produce_dr_message( @@ -224,9 +224,9 @@ def test_produce_dr_message( ) @conf_vars( { - ("kafka_listener", "task_instance_events_enabled"): "True", - ("kafka_listener", "topic"): _KAFKA_TOPIC, - ("kafka_listener", "source"): _SOURCE, + ("kafka_event_producer", "task_instance_events_enabled"): "True", + ("kafka_event_producer", "topic"): _KAFKA_TOPIC, + ("kafka_event_producer", "source"): _SOURCE, } ) def test_produce_ti_message( @@ -261,15 +261,15 @@ class TestGetProducer: @pytest.fixture(autouse=True) def _producer_conf(self): - with conf_vars({("kafka_listener", "topic"): _KAFKA_TOPIC}): + with conf_vars({("kafka_event_producer", "topic"): _KAFKA_TOPIC}): yield def test_producer_cached_on_success(self): kafka_producer_mock = MagicMock() with patch(_PRODUCER_CLS, return_value=kafka_producer_mock) as producer_cls_mock: - producer1 = listener._get_producer() - producer2 = listener._get_producer() + producer1 = event_producer._get_producer() + producer2 = event_producer._get_producer() assert producer1 is kafka_producer_mock assert producer2 is producer1 @@ -277,10 +277,10 @@ def test_producer_cached_on_success(self): def test_init_failure_warns_and_returns_none(self, monkeypatch): log_warning_mock = MagicMock() - monkeypatch.setattr(listener.log, "warning", log_warning_mock) + monkeypatch.setattr(event_producer.log, "warning", log_warning_mock) with patch(_PRODUCER_CLS, side_effect=ValueError("boom")): - assert listener._get_producer() is None + assert event_producer._get_producer() is None log_warning_mock.assert_called_once() warning_format, exc_arg = log_warning_mock.call_args.args @@ -293,30 +293,30 @@ def test_atexit_register(self, monkeypatch): kafka_producer_mock = MagicMock() register_mock = MagicMock() - monkeypatch.setattr(listener.atexit, "register", register_mock) + monkeypatch.setattr(event_producer.atexit, "register", register_mock) with patch(_PRODUCER_CLS, return_value=kafka_producer_mock): - listener._get_producer() + event_producer._get_producer() - register_mock.assert_called_once_with(listener._flush_producer_at_exit, kafka_producer_mock) + register_mock.assert_called_once_with(event_producer._flush_producer_at_exit, kafka_producer_mock) - def test_hook_built_from_listener_config(self): - """The listener section's ``kafka_config_id`` is forwarded to the hook.""" + def test_hook_built_from_event_producer_config(self): + """The plugin section's ``kafka_config_id`` is forwarded to the hook.""" hook_mock = MagicMock() - with conf_vars({("kafka_listener", "kafka_config_id"): "kafka_events"}): + with conf_vars({("kafka_event_producer", "kafka_config_id"): "kafka_events"}): with patch(_PRODUCER_HOOK_CLS, return_value=hook_mock) as hook_cls_mock: - producer = listener._get_producer() + producer = event_producer._get_producer() hook_cls_mock.assert_called_once_with(kafka_config_id="kafka_events") assert producer is hook_mock.get_producer.return_value def test_hook_defaults_when_options_unset(self): - """Without listener options the hook falls back to its own defaults.""" + """Without plugin options the hook falls back to its own defaults.""" hook_mock = MagicMock() with patch(_PRODUCER_HOOK_CLS, return_value=hook_mock) as hook_cls_mock: - listener._get_producer() + event_producer._get_producer() # Unset options must not be passed to the hook at all (e.g. as ""), so the # hook's own defaults (the "kafka_default" connection) apply. @@ -328,7 +328,7 @@ class TestCheckTopicExists: @pytest.fixture(autouse=True) def _topic_conf(self): - with conf_vars({("kafka_listener", "topic"): _KAFKA_TOPIC}): + with conf_vars({("kafka_event_producer", "topic"): _KAFKA_TOPIC}): yield def test_topic_exists_kept_for_process_lifetime(self): @@ -336,9 +336,9 @@ def test_topic_exists_kept_for_process_lifetime(self): kafka_producer_mock.list_topics.return_value.topics.__contains__.return_value = True with patch(_PRODUCER_CLS, return_value=kafka_producer_mock): - assert listener._check_topic_exists() is True + assert event_producer._check_topic_exists() is True # Once confirmed, the broker is not queried again. - assert listener._check_topic_exists() is True + assert event_producer._check_topic_exists() is True kafka_producer_mock.list_topics.assert_called_once() @@ -347,29 +347,29 @@ def test_topic_doesnt_exist(self, monkeypatch): kafka_producer_mock.list_topics.return_value.topics.__contains__.return_value = False log_warning_mock = MagicMock() - monkeypatch.setattr(listener.log, "warning", log_warning_mock) + monkeypatch.setattr(event_producer.log, "warning", log_warning_mock) with patch(_PRODUCER_CLS, return_value=kafka_producer_mock): - assert listener._check_topic_exists() is False + assert event_producer._check_topic_exists() is False log_warning_mock.assert_called_once() # Format string + the two formatting args are passed positionally to log.warning. warning_format, topic_arg, retry_interval_arg = log_warning_mock.call_args.args assert "topic %r not found on the broker" in warning_format assert topic_arg == _KAFKA_TOPIC - assert retry_interval_arg == listener._get_topic_check_retry_interval() + assert retry_interval_arg == event_producer._get_topic_check_retry_interval() def test_list_topics_failure_warns_and_sets_cooldown(self, monkeypatch): kafka_producer_mock = MagicMock() kafka_producer_mock.list_topics.side_effect = ValueError("broker down") log_warning_mock = MagicMock() - monkeypatch.setattr(listener.log, "warning", log_warning_mock) + monkeypatch.setattr(event_producer.log, "warning", log_warning_mock) with patch(_PRODUCER_CLS, return_value=kafka_producer_mock): - assert listener._check_topic_exists() is False + assert event_producer._check_topic_exists() is False # Within the cooldown window the check is not retried. - assert listener._check_topic_exists() is False + assert event_producer._check_topic_exists() is False kafka_producer_mock.list_topics.assert_called_once() log_warning_mock.assert_called_once() @@ -377,7 +377,7 @@ def test_list_topics_failure_warns_and_sets_cooldown(self, monkeypatch): def test_retried_after_failure_cooldown(self, monkeypatch): time_now = [1000.0] - monkeypatch.setattr(listener.time, "monotonic", lambda: time_now[0]) + monkeypatch.setattr(event_producer.time, "monotonic", lambda: time_now[0]) kafka_producer_mock = MagicMock() # The topic doesn't exist yet. @@ -385,36 +385,36 @@ def test_retried_after_failure_cooldown(self, monkeypatch): with patch(_PRODUCER_CLS, return_value=kafka_producer_mock): # First check fails: the topic is missing. - assert listener._check_topic_exists() is False + assert event_producer._check_topic_exists() is False assert kafka_producer_mock.list_topics.call_count == 1 # The interval hasn't passed and the check isn't retried. time_now[0] += 1 - assert listener._check_topic_exists() is False + assert event_producer._check_topic_exists() is False assert kafka_producer_mock.list_topics.call_count == 1 # Past the interval. The check is retried and succeeds this time. kafka_producer_mock.list_topics.return_value.topics.__contains__.return_value = True - time_now[0] += listener._get_topic_check_retry_interval() + 1 - assert listener._check_topic_exists() is True + time_now[0] += event_producer._get_topic_check_retry_interval() + 1 + assert event_producer._check_topic_exists() is True assert kafka_producer_mock.list_topics.call_count == 2 def test_producer_init_failure_sets_cooldown(self, monkeypatch): time_now = [1000.0] - monkeypatch.setattr(listener.time, "monotonic", lambda: time_now[0]) + monkeypatch.setattr(event_producer.time, "monotonic", lambda: time_now[0]) with patch(_PRODUCER_CLS, side_effect=ValueError("boom")) as producer_cls_mock: - assert listener._check_topic_exists() is False + assert event_producer._check_topic_exists() is False assert producer_cls_mock.call_count == 1 # The interval hasn't passed and the producer initialization isn't retried. time_now[0] += 1 - assert listener._check_topic_exists() is False + assert event_producer._check_topic_exists() is False assert producer_cls_mock.call_count == 1 # Past the interval. The producer initialization is retried. - time_now[0] += listener._get_topic_check_retry_interval() + 1 - assert listener._check_topic_exists() is False + time_now[0] += event_producer._get_topic_check_retry_interval() + 1 + assert event_producer._check_topic_exists() is False assert producer_cls_mock.call_count == 2 @pytest.mark.parametrize( @@ -434,27 +434,27 @@ def test_delivery_report_invalidates_topic_confirmation_only_on_missing_topic( """ time_now = [1000.0] - monkeypatch.setattr(listener.time, "monotonic", lambda: time_now[0]) + monkeypatch.setattr(event_producer.time, "monotonic", lambda: time_now[0]) kafka_producer_mock = MagicMock() kafka_producer_mock.list_topics.return_value.topics.__contains__.return_value = True with patch(_PRODUCER_CLS, return_value=kafka_producer_mock): - assert listener._check_topic_exists() is True - assert listener._topic_exists is True + assert event_producer._check_topic_exists() is True + assert event_producer._topic_exists is True err = MagicMock() err.code.return_value = kafka_error - listener._on_delivery(err, MagicMock()) + event_producer._on_delivery(err, MagicMock()) - assert listener._topic_exists is topic_exists_expected + assert event_producer._topic_exists is topic_exists_expected if not topic_exists_expected: # Cooldown holds off the immediate re-check. - assert listener._check_topic_exists() is False + assert event_producer._check_topic_exists() is False # After the cooldown, the check re-verifies; the topic is still on the broker, # so confirmation is restored. - time_now[0] += listener._get_topic_check_retry_interval() + 1 - assert listener._check_topic_exists() is True + time_now[0] += event_producer._get_topic_check_retry_interval() + 1 + assert event_producer._check_topic_exists() is True class TestFilters: @@ -464,9 +464,9 @@ class TestFilters: def _filters_conf(self): with conf_vars( { - ("kafka_listener", "dag_run_events_enabled"): "True", - ("kafka_listener", "task_instance_events_enabled"): "True", - ("kafka_listener", "topic"): _KAFKA_TOPIC, + ("kafka_event_producer", "dag_run_events_enabled"): "True", + ("kafka_event_producer", "task_instance_events_enabled"): "True", + ("kafka_event_producer", "topic"): _KAFKA_TOPIC, } ): yield @@ -482,55 +482,55 @@ def _filters_conf(self): ], ) def test_id_is_allowed(self, id_to_check, allowlist, denylist, expected_result): - assert listener._id_is_allowed(id_to_check, allowlist, denylist) is expected_result + assert event_producer._id_is_allowed(id_to_check, allowlist, denylist) is expected_result @conf_vars( { - ("kafka_listener", "task_instance_dag_id_denylist"): "dag1_*", + ("kafka_event_producer", "task_instance_dag_id_denylist"): "dag1_*", } ) def test_dag_run_filter_ignores_ti_lists(self): # The dag_id lists for DR events, are separate from the dag_id lists for TI events. # The dag_id denylist for task instances would match "dag1_task1" but DR filter must ignore it. - assert listener._dag_run_event_allowed("dag1_task1") is True + assert event_producer._dag_run_event_allowed("dag1_task1") is True @conf_vars( { - ("kafka_listener", "dag_run_dag_id_denylist"): "dag1_*", + ("kafka_event_producer", "dag_run_dag_id_denylist"): "dag1_*", } ) def test_task_instance_filter_ignores_dr_lists(self): # The dag_id lists for DR events, are separate from the dag_id lists for TI events. # DR denylist would match "dag1_task1" but TI filter must ignore it. - assert listener._task_instance_event_allowed("dag1_task1", "load") is True + assert event_producer._task_instance_event_allowed("dag1_task1", "load") is True @conf_vars( { - ("kafka_listener", "task_instance_dag_id_allowlist"): "dag1_*", - ("kafka_listener", "task_instance_task_id_denylist"): "*_cleanup", + ("kafka_event_producer", "task_instance_dag_id_allowlist"): "dag1_*", + ("kafka_event_producer", "task_instance_task_id_denylist"): "*_cleanup", } ) def test_task_instance_requires_both_dag_id_and_task_id(self): # dag_id passes (matches allowlist), task_id passes (no deny match). - assert listener._task_instance_event_allowed("dag1_task1", "task2") is True + assert event_producer._task_instance_event_allowed("dag1_task1", "task2") is True # dag_id passes, task_id denied. - assert listener._task_instance_event_allowed("dag1_task1", "task2_cleanup") is False + assert event_producer._task_instance_event_allowed("dag1_task1", "task2_cleanup") is False # dag_id denied (no allowlist match), task_id passes. - assert listener._task_instance_event_allowed("other_dag", "load") is False + assert event_producer._task_instance_event_allowed("other_dag", "load") is False - @conf_vars({("kafka_listener", "dag_run_dag_id_denylist"): _DAG_ID}) + @conf_vars({("kafka_event_producer", "dag_run_dag_id_denylist"): _DAG_ID}) def test_dr_filter_blocks_emission(self, dr_mock, kafka_producer_mock): DagRunListener().on_dag_run_running(dag_run=dr_mock, msg=None) kafka_producer_mock.produce.assert_not_called() - @conf_vars({("kafka_listener", "task_instance_dag_id_denylist"): _DAG_ID}) + @conf_vars({("kafka_event_producer", "task_instance_dag_id_denylist"): _DAG_ID}) def test_ti_filter_blocks_emission_via_dag_id(self, ti_mock, kafka_producer_mock): TaskListener().on_task_instance_running( previous_state="queued", task_instance=ti_mock, **_TI_SESSION_KWARG ) kafka_producer_mock.produce.assert_not_called() - @conf_vars({("kafka_listener", "task_instance_task_id_denylist"): _TASK_ID}) + @conf_vars({("kafka_event_producer", "task_instance_task_id_denylist"): _TASK_ID}) def test_ti_filter_blocks_emission_via_task_id(self, ti_mock, kafka_producer_mock): TaskListener().on_task_instance_running( previous_state="queued", task_instance=ti_mock, **_TI_SESSION_KWARG diff --git a/scripts/ci/docker-compose/integration-kafka.yml b/scripts/ci/docker-compose/integration-kafka.yml index f0ba7fab7d619..da67875dfb791 100644 --- a/scripts/ci/docker-compose/integration-kafka.yml +++ b/scripts/ci/docker-compose/integration-kafka.yml @@ -60,9 +60,9 @@ services: airflow: environment: - INTEGRATION_KAFKA=true - - AIRFLOW__KAFKA_LISTENER__DAG_RUN_EVENTS_ENABLED=True - - AIRFLOW__KAFKA_LISTENER__TASK_INSTANCE_EVENTS_ENABLED=True - - AIRFLOW__KAFKA_LISTENER__TOPIC=airflow.events - - AIRFLOW__KAFKA_LISTENER__SOURCE=dev-breeze + - AIRFLOW__KAFKA_EVENT_PRODUCER__DAG_RUN_EVENTS_ENABLED=True + - AIRFLOW__KAFKA_EVENT_PRODUCER__TASK_INSTANCE_EVENTS_ENABLED=True + - AIRFLOW__KAFKA_EVENT_PRODUCER__TOPIC=airflow.events + - AIRFLOW__KAFKA_EVENT_PRODUCER__SOURCE=dev-breeze depends_on: - broker From 9294c2cf0bc596ec2e07bfcb866c53d0e3a3fa3d Mon Sep 17 00:00:00 2001 From: SameerMesiah97 <75502260+SameerMesiah97@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:48:03 +0100 Subject: [PATCH 21/86] Set stream=False for Snowflake Cortex Agent requests. (#69731) --- .../providers/snowflake/hooks/snowflake_cortex_agent.py | 5 ++++- .../unit/snowflake/hooks/test_snowflake_cortex_agent.py | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake_cortex_agent.py b/providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake_cortex_agent.py index 7b7f37a249787..1413d7fee7135 100644 --- a/providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake_cortex_agent.py +++ b/providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake_cortex_agent.py @@ -130,7 +130,10 @@ def run_agent( if thread_id is not None and parent_message_id is None: raise ValueError("parent_message_id must be provided when thread_id is specified.") - payload: dict[str, Any] = {"messages": messages} + payload: dict[str, Any] = { + "messages": messages, + "stream": False, + } if thread_id is not None: payload["thread_id"] = thread_id diff --git a/providers/snowflake/tests/unit/snowflake/hooks/test_snowflake_cortex_agent.py b/providers/snowflake/tests/unit/snowflake/hooks/test_snowflake_cortex_agent.py index 8ec86d532f966..e686b698574ad 100644 --- a/providers/snowflake/tests/unit/snowflake/hooks/test_snowflake_cortex_agent.py +++ b/providers/snowflake/tests/unit/snowflake/hooks/test_snowflake_cortex_agent.py @@ -125,6 +125,7 @@ def test_run_agent( ], } ], + "stream": False, }, timeout=REQUEST_TIMEOUT, ) @@ -175,6 +176,7 @@ def test_run_agent_includes_thread_fields( assert payload["thread_id"] == 123 assert payload["parent_message_id"] == 456 + assert payload["stream"] is False @mock.patch(f"{MODULE_PATH}.requests.request") @mock.patch(f"{HOOK_PATH}._get_conn_params") @@ -215,6 +217,7 @@ def test_run_agent_includes_optional_fields( assert payload["orchestration"] == {"max_tokens": 1000} assert payload["tools"] == [{"name": "search_tool"}] assert payload["tool_resources"] == {"search_tool": {"config": "value"}} + assert payload["stream"] is False @mock.patch(f"{MODULE_PATH}.requests.request") @mock.patch(f"{HOOK_PATH}._get_conn_params") From 5b837845922786f4b4a2d5c5f27c84356786e861 Mon Sep 17 00:00:00 2001 From: Dheeraj Turaga Date: Sat, 18 Jul 2026 07:49:31 -0500 Subject: [PATCH 22/86] Send Airflow CLI logs to stderr for -o commands so structured output stays parseable (#68598) --- airflow-core/src/airflow/__main__.py | 6 +++ airflow-core/src/airflow/cli/utils.py | 15 +++++++ airflow-core/tests/unit/cli/test_utils.py | 51 ++++++++++++++++++++++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/__main__.py b/airflow-core/src/airflow/__main__.py index c11d3b5afe923..c49146b629875 100644 --- a/airflow-core/src/airflow/__main__.py +++ b/airflow-core/src/airflow/__main__.py @@ -35,6 +35,7 @@ # any possible import cycles with settings downstream. from airflow import configuration from airflow.cli import cli_parser +from airflow.cli.utils import redirect_stdout_log_handlers_to_stderr def main(): @@ -45,6 +46,11 @@ def main(): parser = cli_parser.get_parser() argcomplete.autocomplete(parser) args = parser.parse_args() + # Commands that accept ``-o`` produce structured output on stdout; route any + # console log handler currently writing to stdout to stderr so log lines do + # not corrupt that output (e.g. ``airflow ... -o json | jq``). + if hasattr(args, "output"): + redirect_stdout_log_handlers_to_stderr() if args.subcommand not in ["lazy_loaded", "version"]: # Here we ensure that the default configuration is written if needed before running any command # that might need it. This used to be done during configuration initialization but having it diff --git a/airflow-core/src/airflow/cli/utils.py b/airflow-core/src/airflow/cli/utils.py index 35a62c9df9135..aef66a8804622 100644 --- a/airflow-core/src/airflow/cli/utils.py +++ b/airflow-core/src/airflow/cli/utils.py @@ -17,6 +17,7 @@ from __future__ import annotations +import logging import sys from collections.abc import Callable from typing import TYPE_CHECKING, TypeVar @@ -78,6 +79,20 @@ def is_stdout(fileio: IOBase) -> bool: return fileio is sys.stdout +def redirect_stdout_log_handlers_to_stderr() -> None: + """ + Redirect any root-logger ``StreamHandler`` writing to stdout so it writes to stderr. + + Called from the CLI entrypoint for commands that emit structured output on + stdout (``-o json|yaml|plain|table``), so log lines do not corrupt that + output. ``FileHandler`` is a ``StreamHandler`` subclass; the identity check + against ``sys.stdout`` correctly skips it. + """ + for handler in logging.getLogger().handlers: + if isinstance(handler, logging.StreamHandler) and handler.stream is sys.stdout: + handler.setStream(sys.stderr) + + def print_export_output(command_type: str, exported_items: Collection, file: TextIOWrapper): if is_stdout(file): print(f"\n{len(exported_items)} {command_type} successfully exported.", file=sys.stderr) diff --git a/airflow-core/tests/unit/cli/test_utils.py b/airflow-core/tests/unit/cli/test_utils.py index f98a9ef6b193d..bd1556dd31038 100644 --- a/airflow-core/tests/unit/cli/test_utils.py +++ b/airflow-core/tests/unit/cli/test_utils.py @@ -17,9 +17,13 @@ # under the License. from __future__ import annotations +import logging +import sys import warnings -from airflow.cli.utils import deprecated_for_airflowctl +import pytest + +from airflow.cli.utils import deprecated_for_airflowctl, redirect_stdout_log_handlers_to_stderr class TestDeprecatedForAirflowctl: @@ -48,3 +52,48 @@ def command(a, b, *, c): assert command.__name__ == "command" assert command.__doc__ == "Original docstring." assert command._migrated_to_airflowctl == "airflowctl pools create" + + +class TestRedirectStdoutLogHandlersToStderr: + """Tests for the CLI helper that keeps logs off stdout for ``-o``-style commands.""" + + @pytest.fixture + def isolated_root_logger(self): + """Snapshot and restore root logger handlers so tests don't leak state.""" + root = logging.getLogger() + original_handlers = root.handlers[:] + root.handlers = [] + try: + yield root + finally: + root.handlers = original_handlers + + @pytest.mark.parametrize( + ("make_handler", "expected_stream"), + [ + pytest.param( + lambda _tmp_path: logging.StreamHandler(stream=sys.stdout), + lambda _original: sys.stderr, + id="stdout-stream-handler-redirected", + ), + pytest.param( + lambda _tmp_path: logging.StreamHandler(stream=sys.stderr), + lambda _original: sys.stderr, + id="stderr-stream-handler-untouched", + ), + pytest.param( + lambda tmp_path: logging.FileHandler(tmp_path / "airflow.log"), + lambda original: original, + id="file-handler-untouched", + ), + ], + ) + def test_redirect(self, isolated_root_logger, tmp_path, make_handler, expected_stream): + handler = make_handler(tmp_path) + isolated_root_logger.addHandler(handler) + original_stream = handler.stream + try: + redirect_stdout_log_handlers_to_stderr() + assert handler.stream is expected_stream(original_stream) + finally: + handler.close() From 2de67894df5dd6f5345f2cc2fbc8e30ca5395aa1 Mon Sep 17 00:00:00 2001 From: "Jason(Zhe-You) Liu" <68415893+jason810496@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:05:17 +0800 Subject: [PATCH 23/86] Document task subprocess lifecycle and pre-connect log buffer (#70076) --- contributing-docs/30_new_language_sdk.rst | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/contributing-docs/30_new_language_sdk.rst b/contributing-docs/30_new_language_sdk.rst index f1fc2245909dd..21af384bb093f 100644 --- a/contributing-docs/30_new_language_sdk.rst +++ b/contributing-docs/30_new_language_sdk.rst @@ -237,6 +237,30 @@ match responses to requests. The SDK MUST correlate by ``id`` whenever multiple requests can be outstanding simultaneously (e.g. when tasks are executed concurrently, or when a single task issues multiple requests). +Task subprocess lifecycle +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Before implementing logging, it helps to see the full lifecycle of the +subprocess relative to the ``--logs`` socket: + +.. code-block:: text + + 1. Supervisor launches the subprocess with + --comm=: --logs=: + │ + └─► 2. Subprocess starts. Its logger is already active, but the + --logs socket is not connected yet. + │ + └─► 3. Subprocess connects to --comm and --logs. + │ + └─► 4. Each subsequent record is sent over the + --logs socket. + +The gap is stage 2: the SDK's own startup code (argument parsing, the connect +calls themselves) can already produce log records before the ``--logs`` socket +in stage 3 exists to carry them. See `Logging`_ below for how to handle that +gap. + Logging ~~~~~~~ @@ -246,6 +270,11 @@ during execution to Airflow's task log store, so they appear in the UI with the rest of the output. Records travel over the ``--logs`` socket introduced in `Startup`_. +Records produced during stage 2 of the `Task subprocess lifecycle`_ — before +the ``--logs`` socket connects — MUST NOT be dropped. Buffer them in memory and +flush the buffer, in order, as soon as the socket connects, before sending any +later record. Both the Go SDK and Java SDK already implement this buffer. + Log messages should be **newline-delimited JSON**. Each log is a UTF-8 encoded JSON object in one line, terminated by a newline character (``\n``). Each object is a structlog-style event: From 1a4d8db0c6a121bce65dc9b4c879029fb15759a5 Mon Sep 17 00:00:00 2001 From: Jake McGrath <116606359+jroachgolf84@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:10:50 -0400 Subject: [PATCH 24/86] Enhancing docs for `HttpEventTrigger` (#70073) * docs/http-event-trigger: Enhancing docs for HttpEventTrigger * Apply suggestions from code review Co-authored-by: Tzu-ping Chung --------- Co-authored-by: Tzu-ping Chung --- providers/http/docs/triggers.rst | 44 ++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/providers/http/docs/triggers.rst b/providers/http/docs/triggers.rst index f14bf15c6297f..d394df6ee1979 100644 --- a/providers/http/docs/triggers.rst +++ b/providers/http/docs/triggers.rst @@ -21,10 +21,11 @@ HTTP Event Trigger .. _howto/trigger:HttpEventTrigger: -The ``HttpEventTrigger`` is an event-based trigger that monitors whether responses -from an API meet the conditions set by the user in the ``response_check`` callable. +The ``HttpEventTrigger`` is an event-based trigger that monitors whether responses from an API meet the conditions set +by the user in the ``response_check_path`` callable. If the condition is met, then the trigger "fires". Otherwise, it waits +a certain amount of time before repeating the request and check. -It is designed for **Airflow 3.0+** to be used in combination with the ``AssetWatcher`` system, +The ``HttpEventTrigger`` is designed for **Airflow 3.0+** to be used in combination with the ``AssetWatcher`` system, enabling event-driven DAGs based on API responses. How It Works @@ -40,7 +41,7 @@ How It Works Usage Example with AssetWatcher ------------------------------- -Here's an example of using the HttpEventTrigger in an AssetWatcher to monitor the GitHub API for new Airflow releases. +Here's an example of using the ``HttpEventTrigger`` in an ``AssetWatcher`` to monitor the GitHub API for new Airflow releases. .. code-block:: python @@ -64,18 +65,27 @@ Here's an example of using the HttpEventTrigger in an AssetWatcher to monitor th async def check_github_api_response(response): + """Determine if a new version of Airflow has been released.""" + # Convert Variable.get to be asynchronous to be used on the Triggerer + get_variable_async = sync_to_async(Variable.get) + + # Retrieve the previous and current release IDs (one from Variables, one from response) + previous_release_id = await get_variable_async(key="release_id_var", default=None) data = response.json() release_id = str(data["id"]) - get_variable_sync = sync_to_async(Variable.get) - previous_release_id = await get_variable_sync(key="release_id_var", default=None) + if release_id == previous_release_id: return False + + # Parse and persist Airflow release data to be used in the downstream Task release_name = data["name"] release_html_url = data["html_url"] - set_variable_sync = sync_to_async(Variable.set) - await set_variable_sync(key="release_id_var", value=str(release_id)) - await set_variable_sync(key="release_name_var", value=release_name) - await set_variable_sync(key="release_html_url_var", value=release_html_url) + set_variable_async = sync_to_async(Variable.set) + + await set_variable_async(key="release_id_var", value=str(release_id)) + await set_variable_async(key="release_name_var", value=release_name) + await set_variable_async(key="release_html_url_var", value=release_html_url) + return True @@ -89,7 +99,7 @@ Here's an example of using the HttpEventTrigger in an AssetWatcher to monitor th ) asset = Asset( - "airflow_releases_asset", watchers=[AssetWatcher(name="airflow_releases_watcher", trigger=trigger)] + name="airflow_releases_asset", watchers=[AssetWatcher(name="airflow_releases_watcher", trigger=trigger)] ) @@ -97,6 +107,7 @@ Here's an example of using the HttpEventTrigger in an AssetWatcher to monitor th def check_airflow_releases(): @task() def print_airflow_release_info(): + # Retrieve and output values persisted from the ``response_check_path`` function release_name = Variable.get("release_name_var") release_html_url = Variable.get("release_html_url_var") print(f"{release_name} has been released. Check it out at {release_html_url}") @@ -110,32 +121,31 @@ Parameters ---------- ``http_conn_id`` - http connection id that has the base API url i.e https://www.google.com/ and optional authentication credentials. - Default headers can also be specified in the Extra field in json format. + HTTP Connection ID that has the base API URL, e.g. ``https://www.google.com/``, and optional authentication credentials. Default headers can also be specified in the ``extra`` field in JSON format. ``auth_type`` The auth type for the service ``method`` - the API method to be called + The API method to be called (``GET``, ``POST``, etc.) ``endpoint`` Endpoint to be called, i.e. ``resource/v1/query?`` ``headers`` - Additional headers to be passed through as a dict + Additional headers to be passed through as a ``dict`` ``data`` Payload to be uploaded or request parameters ``extra_options`` - Additional kwargs to pass when creating a request. + Additional keyword arguments to pass when creating a request ``response_check_path`` Path to callable that evaluates whether the API response passes the conditions set by the user to trigger DAGs ``poll_interval`` - How often, in seconds, the trigger should send a request to the API. + How often, in seconds, the trigger should send a request to the API Important Notes From ebaf416cb5fde584db0d1186e74faae7cdd76a38 Mon Sep 17 00:00:00 2001 From: Morgan <62363051+AlejandroMorgante@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:59:46 -0300 Subject: [PATCH 25/86] Add Amazon ECR repository operators (#69886) --- providers/amazon/docs/operators/ecr.rst | 90 +++++++ providers/amazon/provider.yaml | 5 + .../providers/amazon/aws/operators/ecr.py | 231 ++++++++++++++++++ .../providers/amazon/get_provider_info.py | 5 + .../tests/system/amazon/aws/example_ecr.py | 99 ++++++++ .../unit/amazon/aws/operators/test_ecr.py | 208 ++++++++++++++++ 6 files changed, 638 insertions(+) create mode 100644 providers/amazon/docs/operators/ecr.rst create mode 100644 providers/amazon/src/airflow/providers/amazon/aws/operators/ecr.py create mode 100644 providers/amazon/tests/system/amazon/aws/example_ecr.py create mode 100644 providers/amazon/tests/unit/amazon/aws/operators/test_ecr.py diff --git a/providers/amazon/docs/operators/ecr.rst b/providers/amazon/docs/operators/ecr.rst new file mode 100644 index 0000000000000..466e3c72d5794 --- /dev/null +++ b/providers/amazon/docs/operators/ecr.rst @@ -0,0 +1,90 @@ + .. 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. + +======================================= +Amazon Elastic Container Registry (ECR) +======================================= + +`Amazon Elastic Container Registry (ECR) `__ is a managed container registry +for storing, sharing, and deploying container images and artifacts. + +Prerequisite Tasks +------------------ + +.. include:: ../_partials/prerequisite_tasks.rst + +Generic Parameters +------------------ + +.. include:: ../_partials/generic_parameters.rst + +Operators +--------- + +.. _howto/operator:EcrCreateRepositoryOperator: + +Create an ECR repository +======================== + +To create an ECR repository, use +:class:`~airflow.providers.amazon.aws.operators.ecr.EcrCreateRepositoryOperator`. +This operator can provision a repository before a workflow builds or pushes container images. +The operator returns the complete response from the Boto3 ``create_repository`` API operation. + +.. exampleinclude:: /../../amazon/tests/system/amazon/aws/example_ecr.py + :language: python + :dedent: 4 + :start-after: [START howto_operator_ecr_create_repository] + :end-before: [END howto_operator_ecr_create_repository] + +.. _howto/operator:EcrSetRepositoryPolicyOperator: + +Set an ECR repository policy +============================ + +To set the repository policy for an ECR repository, use +:class:`~airflow.providers.amazon.aws.operators.ecr.EcrSetRepositoryPolicyOperator`. +This operator can configure permissions such as cross-account access to images stored in the repository. +The operator returns the complete response from the Boto3 ``set_repository_policy`` API operation. + +.. exampleinclude:: /../../amazon/tests/system/amazon/aws/example_ecr.py + :language: python + :dedent: 4 + :start-after: [START howto_operator_ecr_set_repository_policy] + :end-before: [END howto_operator_ecr_set_repository_policy] + +.. _howto/operator:EcrDeleteRepositoryOperator: + +Delete an ECR repository +======================== + +To delete an ECR repository, use +:class:`~airflow.providers.amazon.aws.operators.ecr.EcrDeleteRepositoryOperator`. +This operator can clean up temporary repositories or repositories that are no longer needed. +Set ``force=True`` to delete a repository that contains images. The operator returns the complete response +from the Boto3 ``delete_repository`` API operation. + +.. exampleinclude:: /../../amazon/tests/system/amazon/aws/example_ecr.py + :language: python + :dedent: 4 + :start-after: [START howto_operator_ecr_delete_repository] + :end-before: [END howto_operator_ecr_delete_repository] + +Reference +--------- + +* `AWS Boto3 library documentation for ECR `__ diff --git a/providers/amazon/provider.yaml b/providers/amazon/provider.yaml index 4fa06f211212d..afc766814820a 100644 --- a/providers/amazon/provider.yaml +++ b/providers/amazon/provider.yaml @@ -182,6 +182,8 @@ integrations: - integration-name: Amazon Elastic Container Registry (ECR) external-doc-url: https://aws.amazon.com/ecr/ logo: /docs/integration-logos/Amazon-Elastic-Container-Registry_light-bg@4x.png + how-to-guide: + - /docs/apache-airflow-providers-amazon/operators/ecr.rst tags: [aws] - integration-name: Amazon ECS external-doc-url: https://aws.amazon.com/ecs/ @@ -447,6 +449,9 @@ operators: - integration-name: Amazon EC2 python-modules: - airflow.providers.amazon.aws.operators.ec2 + - integration-name: Amazon Elastic Container Registry (ECR) + python-modules: + - airflow.providers.amazon.aws.operators.ecr - integration-name: Amazon ECS python-modules: - airflow.providers.amazon.aws.operators.ecs diff --git a/providers/amazon/src/airflow/providers/amazon/aws/operators/ecr.py b/providers/amazon/src/airflow/providers/amazon/aws/operators/ecr.py new file mode 100644 index 0000000000000..dec75ede9b582 --- /dev/null +++ b/providers/amazon/src/airflow/providers/amazon/aws/operators/ecr.py @@ -0,0 +1,231 @@ +# 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. +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, ClassVar + +from airflow.providers.amazon.aws.hooks.ecr import EcrHook +from airflow.providers.amazon.aws.operators.base_aws import AwsBaseOperator +from airflow.providers.amazon.aws.utils.mixins import aws_template_fields +from airflow.utils.helpers import prune_dict + +if TYPE_CHECKING: + from airflow.sdk import Context + + +class EcrCreateRepositoryOperator(AwsBaseOperator[EcrHook]): + """ + Create an Amazon ECR repository. + + .. seealso:: + For more information on how to use this operator, take a look at the guide: + :ref:`howto/operator:EcrCreateRepositoryOperator` + + :param repository_name: The name of the repository to create. (templated) + :param registry_id: The AWS account ID associated with the registry. (templated) + :param tags: Metadata to apply to the repository. (templated) + :param image_tag_mutability: The tag mutability setting for the repository. (templated) + :param image_tag_mutability_exclusion_filters: Filters that override the repository's + image tag mutability setting. (templated) + :param image_scanning_configuration: The image scanning configuration for the repository. (templated) + :param encryption_configuration: The encryption configuration for the repository. (templated) + :param aws_conn_id: The Airflow connection used for AWS credentials. + If this is ``None`` or empty then the default boto3 behaviour is used. If + running Airflow in a distributed manner and aws_conn_id is None or + empty, then default boto3 configuration would be used (and must be + maintained on each worker node). + :param region_name: AWS region_name. If not specified then the default boto3 behaviour is used. + :param verify: Whether or not to verify SSL certificates. See: + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html + :param botocore_config: Configuration dictionary (key-values) for botocore client. See: + https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html + """ + + aws_hook_class = EcrHook + template_fields: Sequence[str] = aws_template_fields( + "repository_name", + "registry_id", + "tags", + "image_tag_mutability", + "image_tag_mutability_exclusion_filters", + "image_scanning_configuration", + "encryption_configuration", + ) + template_fields_renderers: ClassVar[dict[str, str]] = { + "tags": "json", + "image_tag_mutability_exclusion_filters": "json", + "image_scanning_configuration": "json", + "encryption_configuration": "json", + } + + def __init__( + self, + *, + repository_name: str, + registry_id: str | None = None, + tags: list[dict[str, str]] | None = None, + image_tag_mutability: str | None = None, + image_tag_mutability_exclusion_filters: list[dict[str, str]] | None = None, + image_scanning_configuration: dict[str, bool] | None = None, + encryption_configuration: dict[str, str] | None = None, + aws_conn_id: str | None = "aws_default", + **kwargs, + ): + super().__init__(aws_conn_id=aws_conn_id, **kwargs) + self.repository_name = repository_name + self.registry_id = registry_id + self.tags = tags + self.image_tag_mutability = image_tag_mutability + self.image_tag_mutability_exclusion_filters = image_tag_mutability_exclusion_filters + self.image_scanning_configuration = image_scanning_configuration + self.encryption_configuration = encryption_configuration + + def execute(self, context: Context) -> dict[str, Any]: + self.log.info("Creating Amazon ECR repository %s", self.repository_name) + response = self.hook.conn.create_repository( + **prune_dict( + { + "registryId": self.registry_id, + "repositoryName": self.repository_name, + "tags": self.tags, + "imageTagMutability": self.image_tag_mutability, + "imageTagMutabilityExclusionFilters": self.image_tag_mutability_exclusion_filters, + "imageScanningConfiguration": self.image_scanning_configuration, + "encryptionConfiguration": self.encryption_configuration, + } + ) + ) + self.log.info("Created Amazon ECR repository %s", self.repository_name) + return response + + +class EcrSetRepositoryPolicyOperator(AwsBaseOperator[EcrHook]): + """ + Set the repository policy for an Amazon ECR repository. + + .. seealso:: + For more information on how to use this operator, take a look at the guide: + :ref:`howto/operator:EcrSetRepositoryPolicyOperator` + + :param repository_name: The name of the repository to receive the policy. (templated) + :param policy_text: The JSON repository policy text to apply. (templated) + :param registry_id: The AWS account ID associated with the registry. (templated) + :param force: Whether to replace an existing policy that prevents setting a new policy. (templated) + :param aws_conn_id: The Airflow connection used for AWS credentials. + If this is ``None`` or empty then the default boto3 behaviour is used. If + running Airflow in a distributed manner and aws_conn_id is None or + empty, then default boto3 configuration would be used (and must be + maintained on each worker node). + :param region_name: AWS region_name. If not specified then the default boto3 behaviour is used. + :param verify: Whether or not to verify SSL certificates. See: + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html + :param botocore_config: Configuration dictionary (key-values) for botocore client. See: + https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html + """ + + aws_hook_class = EcrHook + template_fields: Sequence[str] = aws_template_fields( + "repository_name", "policy_text", "registry_id", "force" + ) + template_fields_renderers: ClassVar[dict[str, str]] = {"policy_text": "json"} + + def __init__( + self, + *, + repository_name: str, + policy_text: str, + registry_id: str | None = None, + force: bool = False, + aws_conn_id: str | None = "aws_default", + **kwargs, + ): + super().__init__(aws_conn_id=aws_conn_id, **kwargs) + self.repository_name = repository_name + self.policy_text = policy_text + self.registry_id = registry_id + self.force = force + + def execute(self, context: Context) -> dict[str, Any]: + self.log.info("Setting repository policy for Amazon ECR repository %s", self.repository_name) + response = self.hook.conn.set_repository_policy( + **prune_dict( + { + "registryId": self.registry_id, + "repositoryName": self.repository_name, + "policyText": self.policy_text, + "force": self.force, + } + ) + ) + self.log.info("Set repository policy for Amazon ECR repository %s", self.repository_name) + return response + + +class EcrDeleteRepositoryOperator(AwsBaseOperator[EcrHook]): + """ + Delete an Amazon ECR repository. + + .. seealso:: + For more information on how to use this operator, take a look at the guide: + :ref:`howto/operator:EcrDeleteRepositoryOperator` + + :param repository_name: The name of the repository to delete. (templated) + :param registry_id: The AWS account ID associated with the registry. (templated) + :param force: Whether to delete the repository when it contains images. (templated) + :param aws_conn_id: The Airflow connection used for AWS credentials. + If this is ``None`` or empty then the default boto3 behaviour is used. If + running Airflow in a distributed manner and aws_conn_id is None or + empty, then default boto3 configuration would be used (and must be + maintained on each worker node). + :param region_name: AWS region_name. If not specified then the default boto3 behaviour is used. + :param verify: Whether or not to verify SSL certificates. See: + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html + :param botocore_config: Configuration dictionary (key-values) for botocore client. See: + https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html + """ + + aws_hook_class = EcrHook + template_fields: Sequence[str] = aws_template_fields("repository_name", "registry_id", "force") + + def __init__( + self, + *, + repository_name: str, + registry_id: str | None = None, + force: bool = False, + aws_conn_id: str | None = "aws_default", + **kwargs, + ): + super().__init__(aws_conn_id=aws_conn_id, **kwargs) + self.repository_name = repository_name + self.registry_id = registry_id + self.force = force + + def execute(self, context: Context) -> dict[str, Any]: + self.log.info("Deleting Amazon ECR repository %s", self.repository_name) + response = self.hook.conn.delete_repository( + **prune_dict( + { + "registryId": self.registry_id, + "repositoryName": self.repository_name, + "force": self.force, + } + ) + ) + self.log.info("Deleted Amazon ECR repository %s", self.repository_name) + return response diff --git a/providers/amazon/src/airflow/providers/amazon/get_provider_info.py b/providers/amazon/src/airflow/providers/amazon/get_provider_info.py index a5a5c532e7e31..35fdd5756a6d1 100644 --- a/providers/amazon/src/airflow/providers/amazon/get_provider_info.py +++ b/providers/amazon/src/airflow/providers/amazon/get_provider_info.py @@ -94,6 +94,7 @@ def get_provider_info(): "integration-name": "Amazon Elastic Container Registry (ECR)", "external-doc-url": "https://aws.amazon.com/ecr/", "logo": "/docs/integration-logos/Amazon-Elastic-Container-Registry_light-bg@4x.png", + "how-to-guide": ["/docs/apache-airflow-providers-amazon/operators/ecr.rst"], "tags": ["aws"], }, { @@ -424,6 +425,10 @@ def get_provider_info(): "integration-name": "Amazon EC2", "python-modules": ["airflow.providers.amazon.aws.operators.ec2"], }, + { + "integration-name": "Amazon Elastic Container Registry (ECR)", + "python-modules": ["airflow.providers.amazon.aws.operators.ecr"], + }, { "integration-name": "Amazon ECS", "python-modules": ["airflow.providers.amazon.aws.operators.ecs"], diff --git a/providers/amazon/tests/system/amazon/aws/example_ecr.py b/providers/amazon/tests/system/amazon/aws/example_ecr.py new file mode 100644 index 0000000000000..64230657cb01e --- /dev/null +++ b/providers/amazon/tests/system/amazon/aws/example_ecr.py @@ -0,0 +1,99 @@ +# 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. +from __future__ import annotations + +from datetime import datetime + +from airflow.providers.amazon.aws.operators.ecr import ( + EcrCreateRepositoryOperator, + EcrDeleteRepositoryOperator, + EcrSetRepositoryPolicyOperator, +) +from airflow.providers.common.compat.sdk import DAG, chain + +from system.amazon.aws.utils import ENV_ID_KEY, SystemTestContextBuilder +from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS + +if AIRFLOW_V_3_0_PLUS: + from airflow.sdk import TriggerRule +else: + from airflow.utils.trigger_rule import TriggerRule # type: ignore[no-redef,attr-defined] + +DAG_ID = "example_ecr" + +sys_test_context_task = SystemTestContextBuilder().build() + + +with DAG( + dag_id=DAG_ID, + schedule=None, + start_date=datetime(2024, 1, 1), + catchup=False, +) as dag: + test_context = sys_test_context_task() + repository_name = f"{test_context[ENV_ID_KEY]}-test-repository" + + # [START howto_operator_ecr_create_repository] + create_repository = EcrCreateRepositoryOperator( + task_id="create_repository", + repository_name=repository_name, + ) + # [END howto_operator_ecr_create_repository] + + # [START howto_operator_ecr_set_repository_policy] + set_repository_policy = EcrSetRepositoryPolicyOperator( + task_id="set_repository_policy", + repository_name=repository_name, + policy_text=""" + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowAccountPull", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::{{ task_instance.xcom_pull(task_ids='create_repository')['repository']['registryId'] }}:root" + }, + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer" + ] + } + ] + } + """, + ) + # [END howto_operator_ecr_set_repository_policy] + + # [START howto_operator_ecr_delete_repository] + delete_repository = EcrDeleteRepositoryOperator( + task_id="delete_repository", + repository_name=repository_name, + force=True, + trigger_rule=TriggerRule.ALL_DONE, + ) + # [END howto_operator_ecr_delete_repository] + + chain(test_context, create_repository, set_repository_policy, delete_repository) + + from tests_common.test_utils.watcher import watcher + + list(dag.tasks) >> watcher() + +from tests_common.test_utils.system_tests import get_test_run # noqa: E402 + +test_run = get_test_run(dag) diff --git a/providers/amazon/tests/unit/amazon/aws/operators/test_ecr.py b/providers/amazon/tests/unit/amazon/aws/operators/test_ecr.py new file mode 100644 index 0000000000000..ba485ba4016d2 --- /dev/null +++ b/providers/amazon/tests/unit/amazon/aws/operators/test_ecr.py @@ -0,0 +1,208 @@ +# 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. +from __future__ import annotations + +from unittest import mock + +import pytest + +from airflow.providers.amazon.aws.hooks.ecr import EcrHook +from airflow.providers.amazon.aws.operators.ecr import ( + EcrCreateRepositoryOperator, + EcrDeleteRepositoryOperator, + EcrSetRepositoryPolicyOperator, +) + +from unit.amazon.aws.utils.test_template_fields import validate_template_fields + +REPOSITORY_NAME = "test-repository" +REGISTRY_ID = "123456789012" +REPOSITORY_RESPONSE = { + "repository": { + "repositoryArn": f"arn:aws:ecr:us-east-1:{REGISTRY_ID}:repository/{REPOSITORY_NAME}", + "registryId": REGISTRY_ID, + "repositoryName": REPOSITORY_NAME, + "repositoryUri": f"{REGISTRY_ID}.dkr.ecr.us-east-1.amazonaws.com/{REPOSITORY_NAME}", + }, + "ResponseMetadata": {"HTTPStatusCode": 200}, +} +POLICY_TEXT = '{"Version":"2012-10-17","Statement":[]}' +POLICY_RESPONSE = { + "registryId": REGISTRY_ID, + "repositoryName": REPOSITORY_NAME, + "policyText": POLICY_TEXT, + "ResponseMetadata": {"HTTPStatusCode": 200}, +} + + +class TestEcrCreateRepositoryOperator: + @pytest.mark.parametrize( + ("operator_parameters", "boto3_parameters"), + [ + pytest.param( + {}, + { + "repositoryName": REPOSITORY_NAME, + }, + id="required-parameters", + ), + pytest.param( + { + "registry_id": REGISTRY_ID, + "tags": [{"Key": "environment", "Value": "test"}], + "image_tag_mutability": "IMMUTABLE_WITH_EXCLUSION", + "image_tag_mutability_exclusion_filters": [ + {"filterType": "WILDCARD", "filter": "latest"} + ], + "image_scanning_configuration": {"scanOnPush": True}, + "encryption_configuration": { + "encryptionType": "KMS", + "kmsKey": "arn:aws:kms:us-east-1:123456789012:key/test-key", + }, + }, + { + "repositoryName": REPOSITORY_NAME, + "registryId": REGISTRY_ID, + "tags": [{"Key": "environment", "Value": "test"}], + "imageTagMutability": "IMMUTABLE_WITH_EXCLUSION", + "imageTagMutabilityExclusionFilters": [{"filterType": "WILDCARD", "filter": "latest"}], + "imageScanningConfiguration": {"scanOnPush": True}, + "encryptionConfiguration": { + "encryptionType": "KMS", + "kmsKey": "arn:aws:kms:us-east-1:123456789012:key/test-key", + }, + }, + id="all-parameters", + ), + ], + ) + @mock.patch.object(EcrHook, "conn", new_callable=mock.PropertyMock) + def test_execute(self, mock_conn, operator_parameters, boto3_parameters): + mock_client = mock.MagicMock(spec=["create_repository"]) + mock_client.create_repository.return_value = REPOSITORY_RESPONSE + mock_conn.return_value = mock_client + operator = EcrCreateRepositoryOperator( + task_id="create_repository", + repository_name=REPOSITORY_NAME, + **operator_parameters, + ) + + result = operator.execute({}) + + mock_client.create_repository.assert_called_once_with(**boto3_parameters) + assert result == REPOSITORY_RESPONSE + + def test_template_fields(self): + operator = EcrCreateRepositoryOperator( + task_id="create_repository", + repository_name=REPOSITORY_NAME, + ) + + validate_template_fields(operator) + + +class TestEcrSetRepositoryPolicyOperator: + @pytest.mark.parametrize( + ("operator_parameters", "boto3_parameters"), + [ + pytest.param( + {}, + { + "repositoryName": REPOSITORY_NAME, + "policyText": POLICY_TEXT, + "force": False, + }, + id="defaults", + ), + pytest.param( + {"registry_id": REGISTRY_ID, "force": True}, + { + "registryId": REGISTRY_ID, + "repositoryName": REPOSITORY_NAME, + "policyText": POLICY_TEXT, + "force": True, + }, + id="registry-and-force", + ), + ], + ) + @mock.patch.object(EcrHook, "conn", new_callable=mock.PropertyMock) + def test_execute(self, mock_conn, operator_parameters, boto3_parameters): + mock_client = mock.MagicMock(spec=["set_repository_policy"]) + mock_client.set_repository_policy.return_value = POLICY_RESPONSE + mock_conn.return_value = mock_client + operator = EcrSetRepositoryPolicyOperator( + task_id="set_repository_policy", + repository_name=REPOSITORY_NAME, + policy_text=POLICY_TEXT, + **operator_parameters, + ) + + result = operator.execute({}) + + mock_client.set_repository_policy.assert_called_once_with(**boto3_parameters) + assert result == POLICY_RESPONSE + + def test_template_fields(self): + operator = EcrSetRepositoryPolicyOperator( + task_id="set_repository_policy", + repository_name=REPOSITORY_NAME, + policy_text=POLICY_TEXT, + ) + + validate_template_fields(operator) + + +class TestEcrDeleteRepositoryOperator: + @pytest.mark.parametrize( + ("operator_parameters", "boto3_parameters"), + [ + pytest.param( + {}, + {"repositoryName": REPOSITORY_NAME, "force": False}, + id="defaults", + ), + pytest.param( + {"registry_id": REGISTRY_ID, "force": True}, + {"repositoryName": REPOSITORY_NAME, "registryId": REGISTRY_ID, "force": True}, + id="registry-and-force", + ), + ], + ) + @mock.patch.object(EcrHook, "conn", new_callable=mock.PropertyMock) + def test_execute(self, mock_conn, operator_parameters, boto3_parameters): + mock_client = mock.MagicMock(spec=["delete_repository"]) + mock_client.delete_repository.return_value = REPOSITORY_RESPONSE + mock_conn.return_value = mock_client + operator = EcrDeleteRepositoryOperator( + task_id="delete_repository", + repository_name=REPOSITORY_NAME, + **operator_parameters, + ) + + result = operator.execute({}) + + mock_client.delete_repository.assert_called_once_with(**boto3_parameters) + assert result == REPOSITORY_RESPONSE + + def test_template_fields(self): + operator = EcrDeleteRepositoryOperator( + task_id="delete_repository", + repository_name=REPOSITORY_NAME, + ) + + validate_template_fields(operator) From 3a63de8eef05ebc8090e5e053cf24e27dab0c243 Mon Sep 17 00:00:00 2001 From: Vic Wen Date: Sun, 19 Jul 2026 04:06:23 +0800 Subject: [PATCH 26/86] Prevent negative trigger queue delay metrics (#69971) --- airflow-core/src/airflow/jobs/triggerer_job_runner.py | 6 ++++-- airflow-core/tests/unit/jobs/test_triggerer_job.py | 10 +++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index 12f3f1d60685a..50a899c2cf256 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -979,7 +979,7 @@ def update_triggers(self, requested_trigger_ids: set[int]): if new_trigger_ids: workloads_to_create = self.build_trigger_workloads(new_trigger_ids) - queued_at = time.time() + queued_at = time.monotonic() for workload in workloads_to_create: workload.queued_at = queued_at @@ -1401,9 +1401,11 @@ async def create_triggers(self): inlets=[Asset(name=name, uri=uri) for name, uri in workload.watched_assets.items()] ) if workload.queued_at is not None: + # `queued_at` is captured by the supervisor process and consumed by the runner process. + # Both run on the same host, so their monotonic timestamps are comparable. stats.timing( "triggerer.trigger_queue_delay", - int((time.time() - workload.queued_at) * 1000), + int((time.monotonic() - workload.queued_at) * 1000), tags=prune_dict({"team_name": self.team_name}), ) diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py b/airflow-core/tests/unit/jobs/test_triggerer_job.py index c9704c53c23a8..a67b6af52548a 100644 --- a/airflow-core/tests/unit/jobs/test_triggerer_job.py +++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py @@ -1582,9 +1582,9 @@ async def test_create_triggers_emits_queue_delay_metric( runner.team_name = team_name runner.to_create.append(workload) - with patch( - "airflow.jobs.triggerer_job_runner.time.time", - return_value=101.5, + with ( + patch("airflow.jobs.triggerer_job_runner.time.monotonic", return_value=101.5), + patch("airflow.jobs.triggerer_job_runner.time.time", return_value=90.0), ): await runner.create_triggers() @@ -2291,6 +2291,8 @@ def test_update_triggers_delegates_workload_creation(supervisor_builder, mocker) supervisor = supervisor_builder() supervisor.running_triggers = {1, 3} workload = workloads.RunTrigger(id=2, classpath="some.trigger", encrypted_kwargs="", ti=None) + monotonic = mocker.patch("airflow.jobs.triggerer_job_runner.time.monotonic", return_value=100.0) + mocker.patch("airflow.jobs.triggerer_job_runner.time.time", return_value=90.0) build_trigger_workloads = mocker.patch.object( TriggerRunnerSupervisor, "build_trigger_workloads", autospec=True, return_value=[workload] ) @@ -2299,6 +2301,8 @@ def test_update_triggers_delegates_workload_creation(supervisor_builder, mocker) build_trigger_workloads.assert_called_once_with(supervisor, {2}) assert list(supervisor.creating_triggers) == [workload] + assert workload.queued_at == 100.0 + monotonic.assert_called_once_with() assert supervisor.cancelling_triggers == {1} From 3227d28a59013cc28bb210c279892af03a963b3a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:51:01 +0200 Subject: [PATCH 27/86] Bump the uv-dependency-updates group in /dev/breeze with 2 updates (#70033) Bumps the uv-dependency-updates group in /dev/breeze with 2 updates: [gitpython](https://github.com/gitpython-developers/GitPython) and [prek](https://github.com/j178/prek). Updates `gitpython` from 3.1.50 to 3.1.51 - [Release notes](https://github.com/gitpython-developers/GitPython/releases) - [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES) - [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.50...3.1.51) Updates `prek` from 0.4.8 to 0.4.9 - [Release notes](https://github.com/j178/prek/releases) - [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md) - [Commits](https://github.com/j178/prek/compare/v0.4.8...v0.4.9) --- updated-dependencies: - dependency-name: gitpython dependency-version: 3.1.51 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-dependency-updates - dependency-name: prek dependency-version: 0.4.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-dependency-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev/breeze/uv.lock | 50 +++++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/dev/breeze/uv.lock b/dev/breeze/uv.lock index a89c1cafd1afe..c39154f80eefc 100644 --- a/dev/breeze/uv.lock +++ b/dev/breeze/uv.lock @@ -595,7 +595,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -659,14 +659,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.50" +version = "3.1.51" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/30/a8a0c15f9480dc91b5b7f11ebd26105e5f80898d7ff02da197fef35d8395/gitpython-3.1.51.tar.gz", hash = "sha256:22c9c94bb6b0b9f3c7157c684fece45a414cea204586b600beae6cd4570dcd6d", size = 223519, upload-time = "2026-07-12T13:40:07.922Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, + { url = "https://files.pythonhosted.org/packages/f8/11/1232bb1ba52a230f20ff08e1b3df7cd9d43a71b61cc0a1c37941064fe4c7/gitpython-3.1.51-py3-none-any.whl", hash = "sha256:d5b29685708f3c80a56db040b665bfd69492c8e150b5848532af8013b686c5a4", size = 215246, upload-time = "2026-07-12T13:40:06.43Z" }, ] [[package]] @@ -886,7 +886,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1248,26 +1248,26 @@ wheels = [ [[package]] name = "prek" -version = "0.4.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/46/e436a6eb9fdb4d3fd08d0ab7fdba19fe03a9e994ec810de57869b853bd8e/prek-0.4.8.tar.gz", hash = "sha256:d15d8bef72ab7b02c7dc01458ac9e05b3131534492b5ce9bb11c4f6f636fa868", size = 494570, upload-time = "2026-07-04T12:05:10.941Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/78/b4149c8913ced2e42debb49e261c4788a1ce431e84226921c2e1a7ea8545/prek-0.4.8-py3-none-linux_armv6l.whl", hash = "sha256:1f8f8cdc65836b571824c965daebb81b449f7e4a43894c58621f5708d5a185ed", size = 5668955, upload-time = "2026-07-04T12:04:41.588Z" }, - { url = "https://files.pythonhosted.org/packages/76/5f/7f54a0087b6b2f1751aeb41266d9c15e66fd0055492814798ab818cd0414/prek-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bce1798e96d9e3a6e6abf435da7107e81452f69edb3ca7c6f90a457355ea46e2", size = 6030947, upload-time = "2026-07-04T12:04:43.8Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d6/f2829fc3902920c36b764a386fa303e71a8219dac25cb3827c575e84199a/prek-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab3a52db17254d701c3cebb7eea58c8230aa7c1959aacfd5b5f25de18edb15d1", size = 5572593, upload-time = "2026-07-04T12:04:45.763Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/c5589955bcd5e3e33b67d8bc3110818cecac82a38fd6bc8b5dfdc5de421c/prek-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b3fcfd620523bbc3f51a21d7cd63449f659b9e2cf3582de12dd5949e23227b8f", size = 5847150, upload-time = "2026-07-04T12:04:47.419Z" }, - { url = "https://files.pythonhosted.org/packages/2d/9d/1f2dc91bdb79d2c4714b27eac9477a51490fba5b4731330dbbebc76bd345/prek-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42e65bc8425e9d7f1691a13ca1da2e07807d1ba76c35740833354b945131689e", size = 5573738, upload-time = "2026-07-04T12:04:49.125Z" }, - { url = "https://files.pythonhosted.org/packages/81/29/69a7b58e16ecbc5f3989bf4b028018d11a82dcdd320b93d6588d72f32aa7/prek-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f578492a8e0c9bc6b4bf6dfbba8716f647d4cd0769bf10ad6cf336e3096fd392", size = 5981054, upload-time = "2026-07-04T12:04:50.842Z" }, - { url = "https://files.pythonhosted.org/packages/63/cc/9b9850a60c22ed18c7755ebd2d72c6eefb37fac58149d09f6adc4691c2cf/prek-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4335f9d5beb123a3884a7fe34f57c9f0828f4fbb7666beab4298833459b104f", size = 6751350, upload-time = "2026-07-04T12:04:52.529Z" }, - { url = "https://files.pythonhosted.org/packages/01/e5/c425aa7272b430630119e6757def3a2007555ba8cbeb2630e0448e7a8b7f/prek-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18a8747df9c602e052881d3efb14dd7f7d62a59bd7277ae5171c9e7661d59d84", size = 6243881, upload-time = "2026-07-04T12:04:54.703Z" }, - { url = "https://files.pythonhosted.org/packages/1c/da/accd3ad07fd2891d3c2777eb42435439fdf11982c51d60f087c0b6b6e102/prek-0.4.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4db639db481d5f854eff9b3d2108889e613b8c15868bcf6bdd777c7cee577436", size = 5848846, upload-time = "2026-07-04T12:04:56.402Z" }, - { url = "https://files.pythonhosted.org/packages/15/00/3477704635249f21f5f98ce444cd7690c2aa9dc8d146a045db88ef2cd8c5/prek-0.4.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c3890a6f92316d2cf44eb50584e8d2b23a596dd70487022e61186a71a2ac0900", size = 5713942, upload-time = "2026-07-04T12:04:58.311Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e6/3ca4fabaebeadc976d9a92d1d9130674265355ea3b728418bad61583b097/prek-0.4.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:fc7e15c24c591a37c6ffce5b25a021b16c299ac2649f183d812b67d665cd6551", size = 5554725, upload-time = "2026-07-04T12:04:59.96Z" }, - { url = "https://files.pythonhosted.org/packages/a5/46/2ab6aaaeff0cedb8955b2e4032071c8712382bdd423bb849718c3720180d/prek-0.4.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:36fe721704ff0c7624c1167639e23a5fe658bfd38c314f487219c9afd1eeb733", size = 5838595, upload-time = "2026-07-04T12:05:01.861Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8b/91398f2b6cd1629d5d8ca8c85b08eca500814a374313b0193f4aaf6ab6c4/prek-0.4.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:162e544abc394a8124f3a4ad68efee116bad09440e679dbd1675177335c2a432", size = 6357222, upload-time = "2026-07-04T12:05:03.845Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2a/ce5cbfaad36866134a21754640a05ecdba641fcd7ad15aa74cf3443f34f6/prek-0.4.8-py3-none-win32.whl", hash = "sha256:2602e46c8c5da7dfa69f60fcf88c2b57132ac623f49fb08bfb3094298c5f07e3", size = 5354388, upload-time = "2026-07-04T12:05:05.587Z" }, - { url = "https://files.pythonhosted.org/packages/df/03/3bc908bc5f7e430315553e47dfa055f19923a3888f9afe4da19f244b5cbf/prek-0.4.8-py3-none-win_amd64.whl", hash = "sha256:7cb22da60bee41b89c4978c0bea7126a3c0ccc003dae6748cf29b53947815edc", size = 5748221, upload-time = "2026-07-04T12:05:07.559Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a7/4295e6d5f5028171dfeb115ad38ab76bf3fe0c8df91b70d73c79aa760a94/prek-0.4.8-py3-none-win_arm64.whl", hash = "sha256:da70057f577b15d4bd121bf9dd29ee205fd4b4d75a0cafba062e84d7e8b4378b", size = 5574425, upload-time = "2026-07-04T12:05:09.595Z" }, +version = "0.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/df/94ed29398576e03494c5aacda8bfed9536edf348bed29cd09f382f2b9b23/prek-0.4.9.tar.gz", hash = "sha256:f8b86441484a5756f3fdb6f3b201d3d448f8845902a84653d78cbf6f875c424f", size = 492711, upload-time = "2026-07-11T11:04:04.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/02/632446b72103daf92526203bf83cb76043ebce70588632aa2aea3741da07/prek-0.4.9-py3-none-linux_armv6l.whl", hash = "sha256:7b240ad6f679104309a944c4dd427ccc46d9aaf4f53ee07379c02bf7578c2750", size = 5637604, upload-time = "2026-07-11T11:03:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/89daefc85a1db9b8c525731c77121de0a8f57731e81c99d823e919e1f6a5/prek-0.4.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5cbb220d6d77cb047747dcabf421375fdfdd958e01a998a854758698d38fb239", size = 5960396, upload-time = "2026-07-11T11:03:40.953Z" }, + { url = "https://files.pythonhosted.org/packages/42/39/0e448da00671e77740be32cca96530ef64bcdbed178acce7f155b87f6057/prek-0.4.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd85df4c186becdc47b2e6155de8cace99133c7517387e30f08ca15b93ead11f", size = 5524982, upload-time = "2026-07-11T11:03:42.605Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/1beceff9cfcf02817c06f38e726c9875cd003f62cbfa516f282fb3c3109b/prek-0.4.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ba40511145e948d461d6641b556b6ff671b186b0c90743600b9dcb788dd5eb5c", size = 5793691, upload-time = "2026-07-11T11:03:44.106Z" }, + { url = "https://files.pythonhosted.org/packages/7e/17/99e4884c45c46be90e81e220d2bdd5020716963707ef6702361cc398dd91/prek-0.4.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0cc5c06ae448568d076bb5e7ce4d630879d6467b2a89fd79061c349a47826efa", size = 5544549, upload-time = "2026-07-11T11:03:45.564Z" }, + { url = "https://files.pythonhosted.org/packages/0c/4b/63f5fe22d867a6e07b12e8a7887a0f3b193c2ef0fa9ed80649f2d257f4ac/prek-0.4.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31af616c216ec7e47913802b082ca816d707d24738fa58eae0463ae296cbe71e", size = 5948798, upload-time = "2026-07-11T11:03:47.424Z" }, + { url = "https://files.pythonhosted.org/packages/1f/89/90d5005436afb6ab99d3ba6599820fe0f0fcb776f5e9e362b5a19e10cbea/prek-0.4.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a1ef842d7f19879fac2135c42ba15508f2677346ba800bc8bb82e5f43fe6a6ec", size = 6696938, upload-time = "2026-07-11T11:03:49Z" }, + { url = "https://files.pythonhosted.org/packages/2a/62/068dd25e1106e262b0ce69ffcdc594cf52d85aa13f64628f851e458980d4/prek-0.4.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:442ecf6a454c692bc8a2d5a16936a0fe8e3419727ff208e15818932acca9923a", size = 6170870, upload-time = "2026-07-11T11:03:50.407Z" }, + { url = "https://files.pythonhosted.org/packages/8e/42/bb1f3f3d84af4d12bb675ff071305fa776d3525c10626465d9960ad93c21/prek-0.4.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5b3c590252e3d5724ebed3774695712ff7cb554743b25184808aa5f42b06bd4c", size = 5800355, upload-time = "2026-07-11T11:03:51.882Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ca/dfc8312b4a7ce8fa100ce37a843279d108eec7e7fe2ddbe069448acd1642/prek-0.4.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dd283b15dec4da29caa3910bd72c8c9d7df93209770503465ad109d6370cb8e", size = 5655126, upload-time = "2026-07-11T11:03:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/25/8a/b19413cb64b81c18502ea7bbef32897f9126b8e53b58cd656996573dc53c/prek-0.4.9-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:cc25e30e1700a5c7dd9bad665c321e5589b51502bb1bbc6ada45e326d08b428b", size = 5517839, upload-time = "2026-07-11T11:03:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/94/af/900df3f7535e87045df331646c6a01bef6e77dba7f2bdb53483f30cbb988/prek-0.4.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4ff5947deeb9a92e6508dfba8b27962dfb927bf60fd36472c9ae862df96fb38c", size = 5802556, upload-time = "2026-07-11T11:03:56.787Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7b/80e560cbe396d0f8687012769cb2d7d7f3428dd019549225ebf205dea806/prek-0.4.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8320ca167d41855d9c4fed66df599f31f96307cbb0da1311a9fe465152e20bd5", size = 6285747, upload-time = "2026-07-11T11:03:58.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d4/01ae3b99d09559a69befd128859d0036c604608dd9c6c99986592dde3c21/prek-0.4.9-py3-none-win32.whl", hash = "sha256:b1e8d3bc88ddce6414853468ed8126f45d4ae20f2f4677801ade20ad67a826fa", size = 5320862, upload-time = "2026-07-11T11:03:59.803Z" }, + { url = "https://files.pythonhosted.org/packages/28/68/d038b14f0220fed197be8bc93229e6ea7ad460803ff8a26e4b14a8c81f66/prek-0.4.9-py3-none-win_amd64.whl", hash = "sha256:ed1b4f87a13d1565e8731c60db7fa058966049cbb4d8872d160add510a286558", size = 5706850, upload-time = "2026-07-11T11:04:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/02/4a/57d04de49f591088901794cc22f36563a102681e3512ae17ee6085cd2f30/prek-0.4.9-py3-none-win_arm64.whl", hash = "sha256:7eab3900d9ea614c8ea0d0d55a8b708f0c88e43c966dc8b13a4e36c1e398dd16", size = 5540477, upload-time = "2026-07-11T11:04:02.815Z" }, ] [[package]] From 511d9ca89449a488de40a5abbc34d56dc0e64ccf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:51:42 +0200 Subject: [PATCH 28/86] Bump the github-actions-updates group across 1 directory with 9 updates (#70040) Bumps the github-actions-updates group with 9 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/setup-java](https://github.com/actions/setup-java) | `5.4.0` | `5.5.0` | | [actions/setup-node](https://github.com/actions/setup-node) | `6.4.0` | `7.0.0` | | [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action) | `3.0.3` | `3.0.5` | | [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) | `6.2.1` | `6.2.2` | | [github/codeql-action/init](https://github.com/github/codeql-action) | `4.36.2` | `4.37.0` | | [github/codeql-action/autobuild](https://github.com/github/codeql-action) | `4.36.2` | `4.37.0` | | [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.36.2` | `4.37.0` | | [actions/stale](https://github.com/actions/stale) | `10.3.0` | `10.4.0` | | [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `8.2.0` | `8.3.2` | Updates `actions/setup-java` from 5.4.0 to 5.5.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/1bcf9fb12cf4aa7d266a90ae39939e61372fe520...0f481fcb613427c0f801b606911222b5b6f3083a) Updates `actions/setup-node` from 6.4.0 to 7.0.0 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020) Updates `slackapi/slack-github-action` from 3.0.3 to 3.0.5 - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Changelog](https://github.com/slackapi/slack-github-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/slackapi/slack-github-action/compare/45a88b9581bfab2566dc881e2cd66d334e621e2c...0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc) Updates `aws-actions/configure-aws-credentials` from 6.2.1 to 6.2.2 - [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases) - [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/254c19bd240aabef8777f48595e9d2d7b972184b...517a711dbcd0e402f90c77e7e2f81e849156e31d) Updates `github/codeql-action/init` from 4.36.2 to 4.37.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...99df26d4f13ea111d4ec1a7dddef6063f76b97e9) Updates `github/codeql-action/autobuild` from 4.36.2 to 4.37.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...99df26d4f13ea111d4ec1a7dddef6063f76b97e9) Updates `github/codeql-action/analyze` from 4.36.2 to 4.37.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...99df26d4f13ea111d4ec1a7dddef6063f76b97e9) Updates `actions/stale` from 10.3.0 to 10.4.0 - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899...1e223db275d687790206a7acac4d1a11bd6fe629) Updates `astral-sh/setup-uv` from 8.2.0 to 8.3.2 - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/fac544c07dec837d0ccb6301d7b5580bf5edae39...11f9893b081a58869d3b5fccaea48c9e9e46f990) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 5.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-updates - dependency-name: actions/setup-node dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions-updates - dependency-name: slackapi/slack-github-action dependency-version: 3.0.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions-updates - dependency-name: aws-actions/configure-aws-credentials dependency-version: 6.2.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions-updates - dependency-name: github/codeql-action/init dependency-version: 4.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-updates - dependency-name: github/codeql-action/autobuild dependency-version: 4.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-updates - dependency-name: github/codeql-action/analyze dependency-version: 4.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-updates - dependency-name: actions/stale dependency-version: 10.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-updates - dependency-name: astral-sh/setup-uv dependency-version: 8.3.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/airflow-e2e-tests.yml | 2 +- .github/workflows/basic-tests.yml | 4 ++-- .github/workflows/ci-amd.yml | 8 ++++---- .github/workflows/ci-arm.yml | 8 ++++---- .github/workflows/ci-duration-monitor.yml | 2 +- .github/workflows/ci-image-checks.yml | 8 ++++---- .github/workflows/ci-notification.yml | 6 +++--- .github/workflows/codeql-analysis.yml | 8 ++++---- .github/workflows/e2e-flaky-tests-report.yml | 2 +- .github/workflows/generate-constraints.yml | 2 +- .github/workflows/java-sdk-release-verify.yml | 4 ++-- .github/workflows/k8s-tests.yml | 2 +- .github/workflows/publish-docs-to-s3.yml | 2 +- .github/workflows/recheck-old-bug-report.yml | 2 +- .github/workflows/registry-backfill.yml | 6 +++--- .github/workflows/registry-build.yml | 4 ++-- .github/workflows/registry-tests.yml | 2 +- .github/workflows/scheduled-verify-release-calendar.yml | 2 +- .github/workflows/stale.yml | 4 ++-- .github/workflows/ui-e2e-tests.yml | 2 +- .github/workflows/update-constraints-on-push-stable.yml | 2 +- .github/workflows/update-constraints-on-push.yml | 2 +- .github/workflows/upgrade-check.yml | 4 ++-- 23 files changed, 44 insertions(+), 44 deletions(-) diff --git a/.github/workflows/airflow-e2e-tests.yml b/.github/workflows/airflow-e2e-tests.yml index a13a5cccf2d92..3799802a5db72 100644 --- a/.github/workflows/airflow-e2e-tests.yml +++ b/.github/workflows/airflow-e2e-tests.yml @@ -133,7 +133,7 @@ jobs: # example pins a Java 11 toolchain) and this job runs on amd64 and arm64. - name: "Setup Java for the Java SDK build" if: inputs.e2e_test_mode == 'java_sdk' - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: ${{ inputs.java-sdk-version }} diff --git a/.github/workflows/basic-tests.yml b/.github/workflows/basic-tests.yml index 0912b0722ec59..065868d6cc243 100644 --- a/.github/workflows/basic-tests.yml +++ b/.github/workflows/basic-tests.yml @@ -115,7 +115,7 @@ jobs: - name: "Install SVN" run: sudo apt-get update && sudo apt-get install -y subversion - name: "Install Java (for Apache RAT)" - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: '17' @@ -197,7 +197,7 @@ jobs: version: 9 run_install: false - name: "Setup node" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: 'pnpm' diff --git a/.github/workflows/ci-amd.yml b/.github/workflows/ci-amd.yml index 628da7f09006c..cd7d99fae890e 100644 --- a/.github/workflows/ci-amd.yml +++ b/.github/workflows/ci-amd.yml @@ -1035,7 +1035,7 @@ jobs: with: persist-credentials: false - name: Setup Java - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: ${{ env.JAVA_VERSION }} @@ -1205,7 +1205,7 @@ jobs: overwrite: true - name: "Notify Slack (new/changed failures)" if: steps.notification.outputs.action == 'notify_new' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} @@ -1221,7 +1221,7 @@ jobs: # yamllint enable rule:line-length - name: "Notify Slack (still not fixed)" if: steps.notification.outputs.action == 'notify_reminder' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} @@ -1237,7 +1237,7 @@ jobs: # yamllint enable rule:line-length - name: "Notify Slack (all tests passing)" if: steps.notification.outputs.action == 'notify_recovery' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/ci-arm.yml b/.github/workflows/ci-arm.yml index 8c2a6be789cca..d1c79602039f7 100644 --- a/.github/workflows/ci-arm.yml +++ b/.github/workflows/ci-arm.yml @@ -1024,7 +1024,7 @@ jobs: with: persist-credentials: false - name: Setup Java - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: ${{ env.JAVA_VERSION }} @@ -1194,7 +1194,7 @@ jobs: overwrite: true - name: "Notify Slack (new/changed failures)" if: steps.notification.outputs.action == 'notify_new' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} @@ -1210,7 +1210,7 @@ jobs: # yamllint enable rule:line-length - name: "Notify Slack (still not fixed)" if: steps.notification.outputs.action == 'notify_reminder' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} @@ -1226,7 +1226,7 @@ jobs: # yamllint enable rule:line-length - name: "Notify Slack (all tests passing)" if: steps.notification.outputs.action == 'notify_recovery' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/ci-duration-monitor.yml b/.github/workflows/ci-duration-monitor.yml index 08e6b6b265787..f4cc2c38e5688 100644 --- a/.github/workflows/ci-duration-monitor.yml +++ b/.github/workflows/ci-duration-monitor.yml @@ -67,7 +67,7 @@ jobs: - name: "Post duration alert to Slack" if: steps.analyze.outputs.has-regression == 'true' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/ci-image-checks.yml b/.github/workflows/ci-image-checks.yml index b4226608bbf6c..83f6dd1f1b06a 100644 --- a/.github/workflows/ci-image-checks.yml +++ b/.github/workflows/ci-image-checks.yml @@ -278,7 +278,7 @@ jobs: inputs.canary-run == 'true' && matrix.flag == '--docs-only' && steps.inventory-notification.outputs.action == 'notify_new' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} @@ -294,7 +294,7 @@ jobs: inputs.canary-run == 'true' && matrix.flag == '--docs-only' && steps.inventory-notification.outputs.action == 'notify_reminder' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} @@ -310,7 +310,7 @@ jobs: inputs.canary-run == 'true' && matrix.flag == '--docs-only' && steps.inventory-notification.outputs.action == 'notify_recovery' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} @@ -453,7 +453,7 @@ jobs: inputs.canary-run == 'true' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: aws-access-key-id: ${{ secrets.DOCS_AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.DOCS_AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/ci-notification.yml b/.github/workflows/ci-notification.yml index c896a94a937b8..768ede8cdbaf9 100644 --- a/.github/workflows/ci-notification.yml +++ b/.github/workflows/ci-notification.yml @@ -79,7 +79,7 @@ jobs: - name: "Send Slack notification (new/changed failures)" if: steps.notification.outputs.action == 'notify_new' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} @@ -100,7 +100,7 @@ jobs: - name: "Send Slack notification (still not fixed)" if: steps.notification.outputs.action == 'notify_reminder' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} @@ -121,7 +121,7 @@ jobs: - name: "Send Slack notification (all passing)" if: steps.notification.outputs.action == 'notify_recovery' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index cd681028a4701..8794885fd155d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -130,19 +130,19 @@ jobs: - name: Setup Java if: matrix.language == 'java' - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: '11' - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} - name: Autobuild if: matrix.language != 'java' - uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - name: Build Java SDK if: matrix.language == 'java' @@ -150,7 +150,7 @@ jobs: run: ./gradlew classes testClasses - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: # Provide more context to the SARIF output (shows up in run.automationDetails.id field) category: "/language:${{matrix.language}}" diff --git a/.github/workflows/e2e-flaky-tests-report.yml b/.github/workflows/e2e-flaky-tests-report.yml index b4408985236ef..34cb70f971e1c 100644 --- a/.github/workflows/e2e-flaky-tests-report.yml +++ b/.github/workflows/e2e-flaky-tests-report.yml @@ -55,7 +55,7 @@ jobs: - name: "Post report to Slack" if: always() && steps.analyze.outcome == 'success' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/generate-constraints.yml b/.github/workflows/generate-constraints.yml index 900cbb4f35719..7e521ed76cb4c 100644 --- a/.github/workflows/generate-constraints.yml +++ b/.github/workflows/generate-constraints.yml @@ -158,7 +158,7 @@ jobs: fi - name: "Post provider downgrade alert to Slack" if: always() && steps.downgrade-alert.outputs.notify == 'true' - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: token: ${{ secrets.SLACK_BOT_TOKEN }} payload-file-path: ${{ steps.downgrade-alert.outputs.message-file }} diff --git a/.github/workflows/java-sdk-release-verify.yml b/.github/workflows/java-sdk-release-verify.yml index 9384f8746b198..188e0c3b968f8 100644 --- a/.github/workflows/java-sdk-release-verify.yml +++ b/.github/workflows/java-sdk-release-verify.yml @@ -76,7 +76,7 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: temurin java-version: ${{ env.JAVA_VERSION }} @@ -125,7 +125,7 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Set up JDK ${{ env.JAVA_VERSION }} - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: temurin java-version: ${{ env.JAVA_VERSION }} diff --git a/.github/workflows/k8s-tests.yml b/.github/workflows/k8s-tests.yml index 54b4d3dc9c453..5aafb0a792a12 100644 --- a/.github/workflows/k8s-tests.yml +++ b/.github/workflows/k8s-tests.yml @@ -203,7 +203,7 @@ jobs: restore-keys: | lang-sdk-go-v1-${{ runner.os }}-${{ runner.arch }}- - name: "Setup Java for lang-SDK build" - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: ${{ inputs.java-sdk-version }} diff --git a/.github/workflows/publish-docs-to-s3.yml b/.github/workflows/publish-docs-to-s3.yml index 5523743725afa..3aee6cbbde538 100644 --- a/.github/workflows/publish-docs-to-s3.yml +++ b/.github/workflows/publish-docs-to-s3.yml @@ -565,7 +565,7 @@ jobs: sudo /tmp/aws/install --update rm -rf /tmp/aws/ - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: aws-access-key-id: ${{ secrets.DOCS_AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.DOCS_AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/recheck-old-bug-report.yml b/.github/workflows/recheck-old-bug-report.yml index a322f9b945da8..03032d75e11a2 100644 --- a/.github/workflows/recheck-old-bug-report.yml +++ b/.github/workflows/recheck-old-bug-report.yml @@ -28,7 +28,7 @@ jobs: recheck-old-bug-report: runs-on: ["ubuntu-22.04"] steps: - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: only-issue-labels: 'kind:bug' stale-issue-label: 'Stale Bug Report' diff --git a/.github/workflows/registry-backfill.yml b/.github/workflows/registry-backfill.yml index 337b0c4cfba2b..0d7bfc2d65dae 100644 --- a/.github/workflows/registry-backfill.yml +++ b/.github/workflows/registry-backfill.yml @@ -163,7 +163,7 @@ jobs: rm -rf /tmp/aws/ - name: "Configure AWS credentials" - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: aws-access-key-id: ${{ secrets.DOCS_AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.DOCS_AWS_SECRET_ACCESS_KEY }} @@ -223,7 +223,7 @@ jobs: version: 10 - name: "Setup Node.js" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: 'pnpm' @@ -305,7 +305,7 @@ jobs: rm -rf /tmp/aws/ - name: "Configure AWS credentials" - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: aws-access-key-id: ${{ secrets.DOCS_AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.DOCS_AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/registry-build.yml b/.github/workflows/registry-build.yml index b737bdb0f4fe8..308ad409201c3 100644 --- a/.github/workflows/registry-build.yml +++ b/.github/workflows/registry-build.yml @@ -139,7 +139,7 @@ jobs: rm -rf /tmp/aws/ - name: "Configure AWS credentials" - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: aws-access-key-id: ${{ secrets.DOCS_AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.DOCS_AWS_SECRET_ACCESS_KEY }} @@ -237,7 +237,7 @@ jobs: version: 10 - name: "Setup Node.js" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: 'pnpm' diff --git a/.github/workflows/registry-tests.yml b/.github/workflows/registry-tests.yml index 7a0cb3c518eed..5281c271d433f 100644 --- a/.github/workflows/registry-tests.yml +++ b/.github/workflows/registry-tests.yml @@ -50,7 +50,7 @@ jobs: persist-credentials: false - name: "Install uv" - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: python-version: "3.12" diff --git a/.github/workflows/scheduled-verify-release-calendar.yml b/.github/workflows/scheduled-verify-release-calendar.yml index 36fd1b938ddf8..a713c25c247fb 100644 --- a/.github/workflows/scheduled-verify-release-calendar.yml +++ b/.github/workflows/scheduled-verify-release-calendar.yml @@ -50,7 +50,7 @@ jobs: # yamllint disable rule:line-length - name: "Notify Slack on failure" if: failure() - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 41d0ea52050a9..a7b7b83ecb019 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -30,7 +30,7 @@ jobs: runs-on: ["ubuntu-22.04"] steps: # Handle all PRs (45-day stale) and pending-response issues (14-day stale) - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: stale-pr-message: > This pull request has been automatically marked as stale because it has not had @@ -50,7 +50,7 @@ jobs: close-issue-message: > This issue has been closed because it has not received response from the issue author. # Handle PRs with pending-response label (7-day stale, faster response expected) - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: only-pr-labels: 'pending-response' days-before-pr-stale: 7 diff --git a/.github/workflows/ui-e2e-tests.yml b/.github/workflows/ui-e2e-tests.yml index ee0e417d9632d..f33ee0fcfc73a 100644 --- a/.github/workflows/ui-e2e-tests.yml +++ b/.github/workflows/ui-e2e-tests.yml @@ -126,7 +126,7 @@ jobs: version: 9 run_install: false - name: "Setup node" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - name: "Compile UI assets (for image build fallback)" diff --git a/.github/workflows/update-constraints-on-push-stable.yml b/.github/workflows/update-constraints-on-push-stable.yml index 9a86d06b47b3f..9278a1b8afece 100644 --- a/.github/workflows/update-constraints-on-push-stable.yml +++ b/.github/workflows/update-constraints-on-push-stable.yml @@ -166,7 +166,7 @@ jobs: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} steps: - name: "Send Slack notification" - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/update-constraints-on-push.yml b/.github/workflows/update-constraints-on-push.yml index 064cecc20ae2e..d2ffaa7198199 100644 --- a/.github/workflows/update-constraints-on-push.yml +++ b/.github/workflows/update-constraints-on-push.yml @@ -235,7 +235,7 @@ jobs: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} steps: - name: "Send Slack notification" - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/upgrade-check.yml b/.github/workflows/upgrade-check.yml index cd3dce4d4e510..ea0f1d57c3aa6 100644 --- a/.github/workflows/upgrade-check.yml +++ b/.github/workflows/upgrade-check.yml @@ -103,7 +103,7 @@ jobs: [${{ inputs.target-branch }}] Notify Slack on success if: success() && steps.find-pr.outputs.pr-url != '' uses: >- - slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c + slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} @@ -132,7 +132,7 @@ jobs: [${{ inputs.target-branch }}] Notify Slack on failure if: failure() uses: >- - slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c + slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc with: method: chat.postMessage token: ${{ env.SLACK_BOT_TOKEN }} From e2a600d67f51c89501cd984b659c5b8d60230682 Mon Sep 17 00:00:00 2001 From: PoAn Yang Date: Sun, 19 Jul 2026 06:54:11 +0900 Subject: [PATCH 29/86] Fix CI integration tests gated by the wrong platform (#70015) Signed-off-by: PoAn Yang --- .github/workflows/ci-amd.yml | 1 + .github/workflows/ci-arm.yml | 1 + dev/breeze/doc/ci/04_selective_checks.md | 9 + .../doc/images/output_ci_selective-check.svg | 46 ++- .../doc/images/output_ci_selective-check.txt | 2 +- .../airflow_breeze/commands/ci_commands.py | 14 + .../commands/ci_commands_config.py | 1 + .../src/airflow_breeze/global_constants.py | 11 +- .../airflow_breeze/utils/selective_checks.py | 89 +---- dev/breeze/tests/conftest.py | 8 - dev/breeze/tests/test_selective_checks.py | 332 +++--------------- scripts/ci/prek/check_ci_workflows_in_sync.py | 7 +- 12 files changed, 124 insertions(+), 397 deletions(-) diff --git a/.github/workflows/ci-amd.yml b/.github/workflows/ci-amd.yml index cd7d99fae890e..9edb394a6cc7c 100644 --- a/.github/workflows/ci-amd.yml +++ b/.github/workflows/ci-amd.yml @@ -226,6 +226,7 @@ jobs: COMMIT_REF: "${{ github.sha }}" VERBOSE: "false" GITHUB_CONTEXT_INPUT: "${{ runner.temp }}/github_context.json" + PLATFORM: "linux/amd64" run: breeze ci selective-check 2>> ${GITHUB_OUTPUT} - name: env run: printenv diff --git a/.github/workflows/ci-arm.yml b/.github/workflows/ci-arm.yml index d1c79602039f7..8f5ce3ac07987 100644 --- a/.github/workflows/ci-arm.yml +++ b/.github/workflows/ci-arm.yml @@ -215,6 +215,7 @@ jobs: COMMIT_REF: "${{ github.sha }}" VERBOSE: "false" GITHUB_CONTEXT_INPUT: "${{ runner.temp }}/github_context.json" + PLATFORM: "linux/arm64" run: breeze ci selective-check 2>> ${GITHUB_OUTPUT} - name: env run: printenv diff --git a/dev/breeze/doc/ci/04_selective_checks.md b/dev/breeze/doc/ci/04_selective_checks.md index ebc6e54586333..59d55d1117840 100644 --- a/dev/breeze/doc/ci/04_selective_checks.md +++ b/dev/breeze/doc/ci/04_selective_checks.md @@ -227,6 +227,15 @@ The same matched-file approach drives the **prek hook skip list** (`skip_prek_ho compile / lint hook is skipped when nothing in its area changed. See [Skipping prek hooks](#skipping-prek-hooks-static-checks). +#### The run's platform (`--platform`) + +Some integrations and providers only work on one CPU architecture, so the selection above is filtered +by the platform the run's tests will execute on: + +* **Integrations** in `DISABLE_TESTABLE_INTEGRATIONS_FROM_ARM` are dropped on ARM. +* **Providers** that declare `excluded-platforms` in their `provider.yaml` (e.g. `ibm.mq` excludes + `linux/arm64`) are removed from the providers test-type matrix on that platform. + ## Individually simple rules The list of rules is long, but each rule is a one-liner you can reason about in isolation. A few diff --git a/dev/breeze/doc/images/output_ci_selective-check.svg b/dev/breeze/doc/images/output_ci_selective-check.svg index 58bccf85cd0b1..6c5e30647948e 100644 --- a/dev/breeze/doc/images/output_ci_selective-check.svg +++ b/dev/breeze/doc/images/output_ci_selective-check.svg @@ -1,4 +1,4 @@ - + --default-branch            Branch against which the PR should be run [default: main](TEXT) --default-constraints-branchConstraints Branch against which the PR should be run [default: constraints-main] (TEXT) -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ GitHub parameters ──────────────────────────────────────────────────────────────────────────────────────────────────╮ ---github-event-name   Name of the GitHub event that triggered the check [default: pull_request](pull_request  -| pull_request_review | pull_request_target | pull_request_workflow | push | schedule |  -workflow_dispatch | workflow_run) ---github-repository   -gGitHub repository used to pull, push run images. [default: apache/airflow](TEXT) ---github-actor        Actor that triggered the event (GitHub user) (TEXT) ---github-context      GitHub context (JSON formatted) passed by GitHub Actions (TEXT) ---github-context-inputFile input (might be `-`) with JSON-formatted github context. Use this instead of        ---github-context for large contexts that would exceed ARG_MAX as an env var. (FILENAME) -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ ---verbose-vPrint verbose information about performed steps. ---dry-run-DIf dry-run is set, commands are only printed, not executed. ---help   -hShow this message and exit. -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +--platform                  Platform the tests of this run execute on. It decides which integrations and providers +are testable. [default: linux/amd64](linux/amd64 | linux/arm64) +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ GitHub parameters ──────────────────────────────────────────────────────────────────────────────────────────────────╮ +--github-event-name   Name of the GitHub event that triggered the check [default: pull_request](pull_request  +| pull_request_review | pull_request_target | pull_request_workflow | push | schedule |  +workflow_dispatch | workflow_run) +--github-repository   -gGitHub repository used to pull, push run images. [default: apache/airflow](TEXT) +--github-actor        Actor that triggered the event (GitHub user) (TEXT) +--github-context      GitHub context (JSON formatted) passed by GitHub Actions (TEXT) +--github-context-inputFile input (might be `-`) with JSON-formatted github context. Use this instead of        +--github-context for large contexts that would exceed ARG_MAX as an env var. (FILENAME) +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Common options ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +--verbose-vPrint verbose information about performed steps. +--dry-run-DIf dry-run is set, commands are only printed, not executed. +--help   -hShow this message and exit. +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/dev/breeze/doc/images/output_ci_selective-check.txt b/dev/breeze/doc/images/output_ci_selective-check.txt index c4b2ee8c47cdb..625c3a3b75037 100644 --- a/dev/breeze/doc/images/output_ci_selective-check.txt +++ b/dev/breeze/doc/images/output_ci_selective-check.txt @@ -1 +1 @@ -e1fc891c482e1d0ac98370259cdabebf +bc06116a5cbad927acd981e56b6a5411 diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands.py b/dev/breeze/src/airflow_breeze/commands/ci_commands.py index 02b9967f29ff3..f7d0b82b02b5b 100644 --- a/dev/breeze/src/airflow_breeze/commands/ci_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/ci_commands.py @@ -41,6 +41,8 @@ option_verbose, ) from airflow_breeze.global_constants import ( + CI_AMD_PLATFORM, + CI_PLATFORMS, DEFAULT_PYTHON_MAJOR_MINOR_VERSION, MILESTONE_BUG_LABELS, MILESTONE_SKIP_LABELS, @@ -236,6 +238,16 @@ def get_changed_files(commit_ref: str | None) -> tuple[str, ...]: type=str, default="", ) +@click.option( + "--platform", + "ci_platform", + type=BetterChoice(CI_PLATFORMS), + default=CI_AMD_PLATFORM, + help="Platform the tests of this run execute on. " + "It decides which integrations and providers are testable.", + envvar="PLATFORM", + show_default=True, +) @click.option( "--github-context", help="GitHub context (JSON formatted) passed by GitHub Actions", @@ -262,6 +274,7 @@ def selective_check( github_actor: str, github_context: str, github_context_input: StringIO | None, + ci_platform: str, ): try: from airflow_breeze.utils.selective_checks import SelectiveChecks @@ -287,6 +300,7 @@ def selective_check( github_repository=github_repository, github_actor=github_actor, github_context_dict=github_context_dict, + platform=ci_platform, ) print(str(sc), file=sys.stderr) except Exception: diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py b/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py index ae5bc64a5b760..61b62e6ed8ffc 100644 --- a/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py +++ b/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py @@ -46,6 +46,7 @@ "--pr-labels", "--default-branch", "--default-constraints-branch", + "--platform", ], }, { diff --git a/dev/breeze/src/airflow_breeze/global_constants.py b/dev/breeze/src/airflow_breeze/global_constants.py index c24b92dd795d3..d7fca41779d81 100644 --- a/dev/breeze/src/airflow_breeze/global_constants.py +++ b/dev/breeze/src/airflow_breeze/global_constants.py @@ -37,13 +37,10 @@ PUBLIC_AMD_RUNNERS = '["ubuntu-22.04"]' PUBLIC_ARM_RUNNERS = '["ubuntu-22.04-arm"]' -# The runner type cross-mapping is intentional — if the previous scheduled build used AMD, the current scheduled build should run with ARM. -RUNNERS_TYPE_CROSS_MAPPING = { - "ubuntu-22.04": '["ubuntu-22.04-arm"]', - "ubuntu-22.04-arm": '["ubuntu-22.04"]', - "windows-2022": '["windows-2022"]', - "windows-2025": '["windows-2025"]', -} +# Platform the tests of a CI run execute on. +CI_AMD_PLATFORM = "linux/amd64" +CI_ARM_PLATFORM = "linux/arm64" +CI_PLATFORMS = [CI_AMD_PLATFORM, CI_ARM_PLATFORM] ANSWER = "" diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py b/dev/breeze/src/airflow_breeze/utils/selective_checks.py index b912af64e3d9f..970c90e606fce 100644 --- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py +++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py @@ -32,6 +32,8 @@ from airflow_breeze.global_constants import ( ALL_PYTHON_MAJOR_MINOR_VERSIONS, APACHE_AIRFLOW_GITHUB_REPOSITORY, + CI_AMD_PLATFORM, + CI_ARM_PLATFORM, COMMITTERS, CURRENT_KUBERNETES_VERSIONS, CURRENT_MYSQL_VERSIONS, @@ -51,7 +53,6 @@ PROVIDERS_COMPATIBILITY_TESTS_MATRIX, PUBLIC_AMD_RUNNERS, PUBLIC_ARM_RUNNERS, - RUNNERS_TYPE_CROSS_MAPPING, TESTABLE_CORE_INTEGRATIONS, TESTABLE_PROVIDERS_INTEGRATION_OWNERS, TESTABLE_PROVIDERS_INTEGRATIONS, @@ -683,6 +684,7 @@ def __init__( github_repository: str = APACHE_AIRFLOW_GITHUB_REPOSITORY, github_actor: str = "", github_context_dict: dict[str, Any] | None = None, + platform: str = CI_AMD_PLATFORM, ): self._files = files self._default_branch = default_branch @@ -693,6 +695,7 @@ def __init__( self._github_repository = github_repository self._github_actor = github_actor self._github_context_dict = github_context_dict or {} + self._platform = platform self._new_toml: dict[str, Any] = {} self._old_toml: dict[str, Any] = {} @@ -1400,7 +1403,7 @@ def core_test_types_list_as_strings_in_json(self) -> str | None: @cached_property def _platform_excluded_providers(self) -> set[str]: - """Provider ids that opt out of the current ``self.platform`` via provider.yaml. + """Provider ids that opt out of the current platform via provider.yaml. Mirrors the ``excluded-python-versions`` mechanism but keyed by Docker platform string (e.g. ``linux/arm64``) so providers whose native dependencies are unavailable @@ -1408,7 +1411,7 @@ def _platform_excluded_providers(self) -> set[str]: """ excluded: set[str] = set() for provider_id, provider_info in get_provider_dependencies().items(): - if self.platform in provider_info.get("excluded-platforms", []): + if self._platform in provider_info.get("excluded-platforms", []): excluded.add(provider_id) return excluded @@ -1803,80 +1806,6 @@ def selected_providers_list_as_string(self) -> str | None: suspended = set(get_suspended_provider_ids()) return " ".join(sorted(p for p in affected_providers if p not in suspended)) - def get_job_label(self, event_type: str, branch: str): - import requests # type: ignore[import-untyped] - - # The main CI is now split into ci-arm.yml and ci-amd.yml; the old - # ci-amd-arm.yml file no longer exists. This lookup is dormant for the - # main pipeline (which hardcodes runner-type per wrapper) and only - # remains here for the `is_disabled_integration` code path that still - # reads `runner_type`. The API call against a missing workflow returns - # nothing and the caller falls back to PUBLIC_AMD_RUNNERS. - job_name = "Basic tests" - workflow_name = "ci-amd-arm.yml" - headers = {"Accept": "application/vnd.github.v3+json"} - if os.environ.get("GITHUB_TOKEN"): - headers["Authorization"] = f"token {os.environ.get('GITHUB_TOKEN')}" - - url = f"https://api.github.com/repos/{self._github_repository}/actions/workflows/{workflow_name}/runs" - payload = {"event": event_type, "status": "completed", "branch": branch} - - response = requests.get(url, headers=headers, params=payload) - if response.status_code != 200: - try: - error_msg = response.json() - except ValueError: - error_msg = response.text[:200] # Truncate long HTML responses - console_print(f"[red]Error while listing workflow runs error: {error_msg}.\n") - return None - runs = response.json().get("workflow_runs", []) - if not runs: - console_print( - f"[yellow]No runs information found for workflow {workflow_name}, params: {payload}.\n" - ) - return None - jobs_url = runs[0].get("jobs_url") - jobs_response = requests.get(jobs_url, headers=headers) - if jobs_response.status_code != 200: - try: - error_msg = jobs_response.json() - except ValueError: - error_msg = jobs_response.text[:200] - console_print(f"[red]Error while listing jobs error: {error_msg}.\n") - return None - jobs = jobs_response.json().get("jobs", []) - if not jobs: - console_print("[yellow]No jobs information found for jobs %s.\n", jobs_url) - return None - - for job in jobs: - if job_name in job.get("name", ""): - runner_labels = job.get("labels", []) - if "windows-2025" in runner_labels: - continue - if not runner_labels: - console_print("[yellow]No labels found for job {job_name}.\n", jobs_url) - return None - return runner_labels[0] - - return None - - @cached_property - def runner_type(self): - if self._github_event in [GithubEvents.SCHEDULE, GithubEvents.PUSH]: - branch = self._github_context_dict.get("ref_name", "main") - label = self.get_job_label(event_type=str(self._github_event.value), branch=branch) - - return RUNNERS_TYPE_CROSS_MAPPING.get(label, PUBLIC_AMD_RUNNERS) if label else PUBLIC_AMD_RUNNERS - - return PUBLIC_AMD_RUNNERS - - @cached_property - def platform(self): - if "arm" in self.runner_type: - return "linux/arm64" - return "linux/amd64" - @cached_property def amd_runners(self) -> str: return PUBLIC_AMD_RUNNERS @@ -1913,10 +1842,8 @@ def excluded_providers_as_string(self) -> str: return json.dumps(sorted_providers_to_exclude) def _is_disabled_integration(self, integration: str) -> bool: - return ( - integration in DISABLE_TESTABLE_INTEGRATIONS_FROM_CI - or integration in DISABLE_TESTABLE_INTEGRATIONS_FROM_ARM - and self.runner_type in PUBLIC_ARM_RUNNERS + return integration in DISABLE_TESTABLE_INTEGRATIONS_FROM_CI or ( + integration in DISABLE_TESTABLE_INTEGRATIONS_FROM_ARM and self._platform == CI_ARM_PLATFORM ) @cached_property diff --git a/dev/breeze/tests/conftest.py b/dev/breeze/tests/conftest.py index 76d92bc0b0aa6..1d8779562e667 100644 --- a/dev/breeze/tests/conftest.py +++ b/dev/breeze/tests/conftest.py @@ -64,11 +64,3 @@ def pytest_unconfigure(config): @pytest.fixture(autouse=True) def clear_clearable_cache(): clear_all_cached_functions() - - -@pytest.fixture -def json_decode_error(): - """Provide a requests.exceptions.JSONDecodeError for mocking API failures.""" - import requests - - return requests.exceptions.JSONDecodeError("", "", 0) diff --git a/dev/breeze/tests/test_selective_checks.py b/dev/breeze/tests/test_selective_checks.py index ae8260db30fc9..3698d41d51b33 100644 --- a/dev/breeze/tests/test_selective_checks.py +++ b/dev/breeze/tests/test_selective_checks.py @@ -27,13 +27,14 @@ from airflow_breeze.global_constants import ( ALLOWED_KUBERNETES_VERSIONS, ALLOWED_PYTHON_MAJOR_MINOR_VERSIONS, + CI_AMD_PLATFORM, + CI_ARM_PLATFORM, DEFAULT_KUBERNETES_VERSION, DEFAULT_PYTHON_MAJOR_MINOR_VERSION, JAVA_SDK_VERSION, NUMBER_OF_CORE_SLICES, NUMBER_OF_LOW_DEP_SLICES, PROVIDERS_COMPATIBILITY_TESTS_MATRIX, - PUBLIC_AMD_RUNNERS, GithubEvents, ) from airflow_breeze.utils.functools_cache import clearable_cache @@ -2513,26 +2514,12 @@ def test_expected_output_pull_request_v2_7( ), ], ) -@patch.dict("os.environ", {"GITHUB_TOKEN": "test_token"}) -@patch("requests.get") def test_expected_output_push( - mock_get, files: tuple[str, ...], pr_labels: tuple[str, ...], default_branch: str, expected_outputs: dict[str, str], ): - # Mock GitHub API calls for runner_type property (used in PUSH events) - workflow_response = Mock() - workflow_response.status_code = 200 - workflow_response.json.return_value = {"workflow_runs": [{"jobs_url": "https://api.github.com/jobs/123"}]} - jobs_response = Mock() - jobs_response.status_code = 200 - jobs_response.json.return_value = { - "jobs": [{"name": "Basic tests (ubuntu-22.04)", "labels": ["ubuntu-22.04"]}] - } - mock_get.side_effect = [workflow_response, jobs_response] - stderr = SelectiveChecks( files=files, commit_ref=NEUTRAL_COMMIT, @@ -2768,20 +2755,7 @@ def test_expected_output_pull_request_target( GithubEvents.SCHEDULE, ], ) -@patch.dict("os.environ", {"GITHUB_TOKEN": "test_token"}) -@patch("requests.get") -def test_no_commit_provided_trigger_full_build_for_any_event_type(mock_get, github_event): - # Mock GitHub API calls for runner_type property (used in PUSH/SCHEDULE events) - workflow_response = Mock() - workflow_response.status_code = 200 - workflow_response.json.return_value = {"workflow_runs": [{"jobs_url": "https://api.github.com/jobs/123"}]} - jobs_response = Mock() - jobs_response.status_code = 200 - jobs_response.json.return_value = { - "jobs": [{"name": "Basic tests (ubuntu-22.04)", "labels": ["ubuntu-22.04"]}] - } - mock_get.side_effect = [workflow_response, jobs_response] - +def test_no_commit_provided_trigger_full_build_for_any_event_type(github_event): stderr = SelectiveChecks( files=(), commit_ref="", @@ -2814,20 +2788,7 @@ def test_no_commit_provided_trigger_full_build_for_any_event_type(mock_get, gith GithubEvents.SCHEDULE, ], ) -@patch.dict("os.environ", {"GITHUB_TOKEN": "test_token"}) -@patch("requests.get") -def test_files_provided_trigger_full_build_for_any_event_type(mock_get, github_event): - # Mock GitHub API calls for runner_type property (used in PUSH/SCHEDULE events) - workflow_response = Mock() - workflow_response.status_code = 200 - workflow_response.json.return_value = {"workflow_runs": [{"jobs_url": "https://api.github.com/jobs/123"}]} - jobs_response = Mock() - jobs_response.status_code = 200 - jobs_response.json.return_value = { - "jobs": [{"name": "Basic tests (ubuntu-22.04)", "labels": ["ubuntu-22.04"]}] - } - mock_get.side_effect = [workflow_response, jobs_response] - +def test_files_provided_trigger_full_build_for_any_event_type(github_event): stderr = SelectiveChecks( files=( "airflow-core/src/airflow/ui/src/pages/Run/Details.tsx", @@ -3229,270 +3190,116 @@ def test_mypy_matches( assert_outputs_are_printed(expected_outputs, str(stderr)) -@patch("requests.get") -@patch.dict("os.environ", {"GITHUB_TOKEN": "test_token"}) -def test_get_job_label(mock_get): - selective_checks = SelectiveChecks( - files=(), - github_event=GithubEvents.PULL_REQUEST, - github_repository="apache/airflow", - github_context_dict={}, - ) - - workflow_response = Mock() - workflow_response.status_code = 200 - workflow_response.json.return_value = {"workflow_runs": [{"jobs_url": "https://api.github.com/jobs/123"}]} - - jobs_response = Mock() - jobs_response.status_code = 200 - jobs_response.json.return_value = { - "jobs": [ - {"name": "Basic tests (ubuntu-22.04)", "labels": ["ubuntu-22.04"]}, - {"name": "Other job", "labels": ["ubuntu-22.04"]}, - ] - } - - mock_get.side_effect = [workflow_response, jobs_response] - - result = selective_checks.get_job_label("push", "main") - - assert result == "ubuntu-22.04" - - -@patch("requests.get") -@patch.dict("os.environ", {"GITHUB_TOKEN": "test_token"}) -def test_get_job_label_not_found(mock_get): - selective_checks = SelectiveChecks( - files=(), - github_event=GithubEvents.PULL_REQUEST, - github_repository="apache/airflow", - github_context_dict={}, - ) - - workflow_response = Mock() - workflow_response.status_code = 200 - workflow_response.json.return_value = {"workflow_runs": [{"jobs_url": "https://api.github.com/jobs/123"}]} - - jobs_response = Mock() - jobs_response.status_code = 200 - jobs_response.json.return_value = { - "jobs": [ - {"name": "Basic tests (ubuntu-22.04)", "labels": []}, - {"name": "Other job", "labels": ["ubuntu-22.04"]}, - ] - } - - mock_get.side_effect = [workflow_response, jobs_response] - - result = selective_checks.get_job_label("push", "main") - - assert result is None - - @pytest.mark.parametrize( - ("workflow_status", "jobs_status", "expected_result"), - [ - pytest.param(504, 200, None, id="workflow_api_504_error"), - pytest.param(200, 503, None, id="jobs_api_503_error"), - pytest.param(200, 200, "ubuntu-22.04", id="both_apis_200_success"), - ], -) -@patch("requests.get") -@patch.dict("os.environ", {"GITHUB_TOKEN": "test_token"}) -def test_get_job_label_api_status_codes( - mock_get, workflow_status, jobs_status, expected_result, json_decode_error -): - """Test that get_job_label handles various HTTP status codes correctly.""" - selective_checks = SelectiveChecks( - files=(), - github_event=GithubEvents.PULL_REQUEST, - github_repository="apache/airflow", - github_context_dict={}, - ) - - workflow_response = Mock() - workflow_response.status_code = workflow_status - if workflow_status == 200: - workflow_response.json.return_value = { - "workflow_runs": [{"jobs_url": "https://api.github.com/jobs/123"}] - } - else: - workflow_response.json.side_effect = json_decode_error - workflow_response.text = "Gateway Timeout" - - jobs_response = Mock() - jobs_response.status_code = jobs_status - if jobs_status == 200: - jobs_response.json.return_value = { - "jobs": [ - {"name": "Basic tests (ubuntu-22.04)", "labels": ["ubuntu-22.04"]}, - {"name": "Other job", "labels": ["ubuntu-22.04"]}, - ] - } - else: - jobs_response.json.side_effect = json_decode_error - jobs_response.text = "Service Unavailable" - - mock_get.side_effect = [workflow_response, jobs_response] - - result = selective_checks.get_job_label("push", "main") - - assert result == expected_result - - -def test_runner_type_pr(): - selective_checks = SelectiveChecks(github_event=GithubEvents.PULL_REQUEST) - - result = selective_checks.runner_type - - assert result == PUBLIC_AMD_RUNNERS - - -@patch("requests.get") -@patch.dict("os.environ", {"GITHUB_TOKEN": "test_token"}) -def test_runner_type_schedule(mock_get): - selective_checks = SelectiveChecks( - files=(), - github_event=GithubEvents.SCHEDULE, - github_repository="apache/airflow", - github_context_dict={}, - ) - - workflow_response = Mock() - workflow_response.status_code = 200 - workflow_response.json.return_value = {"workflow_runs": [{"jobs_url": "https://api.github.com/jobs/123"}]} - - jobs_response = Mock() - jobs_response.status_code = 200 - jobs_response.json.return_value = { - "jobs": [ - {"name": "Basic tests / Test git clone on Windows", "labels": ["windows-2025"]}, - {"name": "Basic tests (ubuntu-22.04)", "labels": ["ubuntu-22.04"]}, - {"name": "Other job", "labels": ["ubuntu-22.04"]}, - ] - } - - mock_get.side_effect = [workflow_response, jobs_response] - - result = selective_checks.runner_type - - assert result == '["ubuntu-22.04-arm"]' - - -@pytest.mark.parametrize( - ("integration", "runner_type", "expected_result"), + ("integration", "platform", "expected_result"), [ # Test integrations disabled for all CI environments pytest.param( "mssql", - PUBLIC_AMD_RUNNERS, + CI_AMD_PLATFORM, True, id="mssql_disabled_on_amd", ), pytest.param( "localstack", - '["ubuntu-22.04-arm"]', + CI_ARM_PLATFORM, True, id="localstack_disabled_on_arm", ), # Test integrations disabled only for ARM runners pytest.param( "kerberos", - '["ubuntu-22.04-arm"]', + CI_ARM_PLATFORM, True, id="kerberos_disabled_on_arm", ), pytest.param( "drill", - '["ubuntu-22.04-arm"]', + CI_ARM_PLATFORM, True, id="drill_disabled_on_arm", ), pytest.param( "tinkerpop", - '["ubuntu-22.04-arm"]', + CI_ARM_PLATFORM, True, id="tinkerpop_disabled_on_arm", ), pytest.param( "pinot", - '["ubuntu-22.04-arm"]', + CI_ARM_PLATFORM, True, id="pinot_disabled_on_arm", ), pytest.param( "trino", - '["ubuntu-22.04-arm"]', + CI_ARM_PLATFORM, True, id="trino_disabled_on_arm", ), pytest.param( "ydb", - '["ubuntu-22.04-arm"]', + CI_ARM_PLATFORM, True, id="ydb_disabled_on_arm", ), # Test integrations that are NOT disabled on AMD runners pytest.param( "kerberos", - PUBLIC_AMD_RUNNERS, + CI_AMD_PLATFORM, False, id="kerberos_enabled_on_amd", ), pytest.param( "drill", - PUBLIC_AMD_RUNNERS, + CI_AMD_PLATFORM, False, id="drill_enabled_on_amd", ), pytest.param( "tinkerpop", - PUBLIC_AMD_RUNNERS, + CI_AMD_PLATFORM, False, id="tinkerpop_enabled_on_amd", ), # Test an integration that is not in any disabled list pytest.param( "postgres", - PUBLIC_AMD_RUNNERS, + CI_AMD_PLATFORM, False, id="postgres_enabled_on_amd", ), pytest.param( "postgres", - '["ubuntu-22.04-arm"]', + CI_ARM_PLATFORM, False, id="postgres_enabled_on_arm", ), pytest.param( "redis", - PUBLIC_AMD_RUNNERS, + CI_AMD_PLATFORM, False, id="redis_enabled_on_amd", ), pytest.param( "redis", - '["ubuntu-22.04-arm"]', + CI_ARM_PLATFORM, False, id="redis_enabled_on_arm", ), ], ) -def test_is_disabled_integration(integration: str, runner_type: str, expected_result: bool): +def test_is_disabled_integration(integration: str, platform: str, expected_result: bool): """Test that _is_disabled_integration correctly identifies disabled integrations.""" selective_checks = SelectiveChecks( files=(), github_event=GithubEvents.PULL_REQUEST, github_repository="apache/airflow", github_context_dict={}, + platform=platform, ) - # Mock the runner_type property - with patch.object( - SelectiveChecks, "runner_type", new_callable=lambda: property(lambda self: runner_type) - ): - result = selective_checks._is_disabled_integration(integration) - assert result == expected_result + assert selective_checks._is_disabled_integration(integration) == expected_result def test_testable_core_integrations_excludes_disabled(): @@ -3506,14 +3313,10 @@ def test_testable_core_integrations_excludes_disabled(): files=("airflow-core/tests/test_example.py",), commit_ref=NEUTRAL_COMMIT, github_event=GithubEvents.PULL_REQUEST, + platform=CI_AMD_PLATFORM, ) - with ( - patch.object( - SelectiveChecks, "runner_type", new_callable=lambda: property(lambda self: PUBLIC_AMD_RUNNERS) - ), - patch.object( - SelectiveChecks, "full_tests_needed", new_callable=lambda: property(lambda self: True) - ), + with patch.object( + SelectiveChecks, "full_tests_needed", new_callable=lambda: property(lambda self: True) ): result = selective_checks_amd.testable_core_integrations assert "postgres" in result @@ -3530,16 +3333,10 @@ def test_testable_core_integrations_excludes_arm_disabled_on_arm(): commit_ref=NEUTRAL_COMMIT, github_event=GithubEvents.SCHEDULE, github_context_dict={"ref_name": "main"}, + platform=CI_ARM_PLATFORM, ) - with ( - patch.object( - SelectiveChecks, - "runner_type", - new_callable=lambda: property(lambda self: '["ubuntu-22.04-arm"]'), - ), - patch.object( - SelectiveChecks, "full_tests_needed", new_callable=lambda: property(lambda self: True) - ), + with patch.object( + SelectiveChecks, "full_tests_needed", new_callable=lambda: property(lambda self: True) ): result = selective_checks_arm.testable_core_integrations assert "postgres" in result @@ -3558,14 +3355,10 @@ def test_testable_providers_integrations_excludes_disabled(): files=("providers/amazon/tests/test_example.py",), commit_ref=NEUTRAL_COMMIT, github_event=GithubEvents.PULL_REQUEST, + platform=CI_AMD_PLATFORM, ) - with ( - patch.object( - SelectiveChecks, "runner_type", new_callable=lambda: property(lambda self: PUBLIC_AMD_RUNNERS) - ), - patch.object( - SelectiveChecks, "full_tests_needed", new_callable=lambda: property(lambda self: True) - ), + with patch.object( + SelectiveChecks, "full_tests_needed", new_callable=lambda: property(lambda self: True) ): result = selective_checks_amd.testable_providers_integrations assert "postgres" in result @@ -3583,16 +3376,10 @@ def test_testable_providers_integrations_excludes_arm_disabled_on_arm(): commit_ref=NEUTRAL_COMMIT, github_event=GithubEvents.SCHEDULE, github_context_dict={"ref_name": "main"}, + platform=CI_ARM_PLATFORM, ) - with ( - patch.object( - SelectiveChecks, - "runner_type", - new_callable=lambda: property(lambda self: '["ubuntu-22.04-arm"]'), - ), - patch.object( - SelectiveChecks, "full_tests_needed", new_callable=lambda: property(lambda self: True) - ), + with patch.object( + SelectiveChecks, "full_tests_needed", new_callable=lambda: property(lambda self: True) ): result = selective_checks_arm.testable_providers_integrations assert "postgres" in result @@ -3616,12 +3403,9 @@ def test_testable_core_integrations_gated_by_source(changed_file, expected_integ files=(changed_file,), commit_ref=NEUTRAL_COMMIT, github_event=GithubEvents.PULL_REQUEST, + platform=CI_AMD_PLATFORM, ) - with patch.object( - SelectiveChecks, "runner_type", new_callable=lambda: property(lambda self: PUBLIC_AMD_RUNNERS) - ): - result = selective_checks.testable_core_integrations - assert result == [expected_integration] + assert selective_checks.testable_core_integrations == [expected_integration] def test_testable_core_integrations_empty_when_unrelated_source(): @@ -3630,11 +3414,9 @@ def test_testable_core_integrations_empty_when_unrelated_source(): files=("airflow-core/src/airflow/models/taskinstance.py",), commit_ref=NEUTRAL_COMMIT, github_event=GithubEvents.PULL_REQUEST, + platform=CI_AMD_PLATFORM, ) - with patch.object( - SelectiveChecks, "runner_type", new_callable=lambda: property(lambda self: PUBLIC_AMD_RUNNERS) - ): - assert selective_checks.testable_core_integrations == [] + assert selective_checks.testable_core_integrations == [] def test_testable_providers_integrations_gated_by_affected_provider(): @@ -3643,15 +3425,13 @@ def test_testable_providers_integrations_gated_by_affected_provider(): files=("providers/apache/cassandra/src/airflow/providers/apache/cassandra/hooks/cassandra.py",), commit_ref=NEUTRAL_COMMIT, github_event=GithubEvents.PULL_REQUEST, + platform=CI_AMD_PLATFORM, ) - with patch.object( - SelectiveChecks, "runner_type", new_callable=lambda: property(lambda self: PUBLIC_AMD_RUNNERS) - ): - result = selective_checks.testable_providers_integrations - assert "cassandra" in result - # Unrelated integrations whose providers are not affected must be absent. - assert "mongo" not in result - assert "ydb" not in result + result = selective_checks.testable_providers_integrations + assert "cassandra" in result + # Unrelated integrations whose providers are not affected must be absent. + assert "mongo" not in result + assert "ydb" not in result def test_individual_providers_excludes_platform_excluded_on_arm(): @@ -3665,14 +3445,11 @@ def test_individual_providers_excludes_platform_excluded_on_arm(): github_context_dict={"ref_name": "main"}, default_branch="main", pr_labels=("full tests needed",), + platform=CI_ARM_PLATFORM, ) - with patch.object( - SelectiveChecks, "runner_type", new_callable=lambda: property(lambda self: '["ubuntu-22.04-arm"]') - ): - assert arm_checks.platform == "linux/arm64" - arm_output = arm_checks.individual_providers_test_types_list_as_strings_in_json - assert arm_output is not None - assert "Providers[ibm.mq]" not in arm_output + arm_output = arm_checks.individual_providers_test_types_list_as_strings_in_json + assert arm_output is not None + assert "Providers[ibm.mq]" not in arm_output amd_checks = SelectiveChecks( files=("airflow-core/tests/test_example.py",), @@ -3681,14 +3458,11 @@ def test_individual_providers_excludes_platform_excluded_on_arm(): github_context_dict={"ref_name": "main"}, default_branch="main", pr_labels=("full tests needed",), + platform=CI_AMD_PLATFORM, ) - with patch.object( - SelectiveChecks, "runner_type", new_callable=lambda: property(lambda self: PUBLIC_AMD_RUNNERS) - ): - assert amd_checks.platform == "linux/amd64" - amd_output = amd_checks.individual_providers_test_types_list_as_strings_in_json - assert amd_output is not None - assert "Providers[ibm.mq]" in amd_output + amd_output = amd_checks.individual_providers_test_types_list_as_strings_in_json + assert amd_output is not None + assert "Providers[ibm.mq]" in amd_output def test_run_kubernetes_tests_forced_by_label(): diff --git a/scripts/ci/prek/check_ci_workflows_in_sync.py b/scripts/ci/prek/check_ci_workflows_in_sync.py index 2e5584c2431dd..14d195f067be9 100755 --- a/scripts/ci/prek/check_ci_workflows_in_sync.py +++ b/scripts/ci/prek/check_ci_workflows_in_sync.py @@ -36,9 +36,11 @@ 4. ``concurrency.group`` prefix — ``ci-arm-`` vs ``ci-amd-`` 5. ``build-info`` outputs ``platform`` and ``runner-type`` — hardcoded per architecture (and the surrounding comment naming the "ARM/AMD copy") -6. ``print-platform`` job — ``name:`` and the architecture echoed to +6. ``PLATFORM`` env on the ``selective-checks`` step — the platform this + workflow's tests run on. +7. ``print-platform`` job — ``name:`` and the architecture echoed to GITHUB_STEP_SUMMARY -7. ``notify-slack`` Slack-state artifact name — ``slack-state-tests-…-arm`` +8. ``notify-slack`` Slack-state artifact name — ``slack-state-tests-…-arm`` vs ``slack-state-tests-…-amd``, so the de-dup tracker in ``slack_notification_state.py`` keeps independent state for each platform on the same branch @@ -85,6 +87,7 @@ (r"^ group: ci-(?:arm|amd)-", " group: ci-PLACEHOLDER-"), (r'^ platform: "linux/(?:arm64|amd64)"$', ' platform: "linux/PLACEHOLDER"'), (r"^ runner-type: '\[\"ubuntu-22\.04(?:-arm)?\"\]'$", " runner-type: 'PLACEHOLDER'"), + (r'^ PLATFORM: "linux/(?:arm64|amd64)"$', ' PLATFORM: "linux/PLACEHOLDER"'), ( r"^ # (?:ARM|AMD) copy\)\. The matching (?:AMD|ARM) copy lives in ci-(?:amd|arm)\.yml\.$", " # PLACEHOLDER copy). The matching PLACEHOLDER copy lives in ci-PLACEHOLDER.yml.", From bc5cace2aaa0022062cdb345afcfa1bff9ba64a2 Mon Sep 17 00:00:00 2001 From: PoAn Yang Date: Sun, 19 Jul 2026 06:55:30 +0900 Subject: [PATCH 30/86] Fix mypy prek hooks skipping all files in dot-directory (#70064) Signed-off-by: PoAn Yang --- scripts/ci/prek/common_prek_utils.py | 5 +++++ scripts/ci/prek/mypy_folder.py | 3 ++- ..._mypy_full_dist_local_venv_or_breeze_in_ci.py | 3 ++- scripts/tests/ci/prek/test_common_prek_utils.py | 16 ++++++++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/scripts/ci/prek/common_prek_utils.py b/scripts/ci/prek/common_prek_utils.py index 26e49175ce607..2f960aa3e32ee 100644 --- a/scripts/ci/prek/common_prek_utils.py +++ b/scripts/ci/prek/common_prek_utils.py @@ -178,6 +178,11 @@ def pre_process_mypy_files(files: list[str]) -> list[str]: return [file for file in files if not file.startswith("providers")] +def is_hidden_within_root(path: Path, root: Path) -> bool: + """Whether any path component below ``root`` is dot-prefixed.""" + return any(part.startswith(".") for part in path.relative_to(root).parts) + + def insert_documentation( file_path: Path, content: list[str], diff --git a/scripts/ci/prek/mypy_folder.py b/scripts/ci/prek/mypy_folder.py index 8825102c0ac1e..43106cf62019c 100755 --- a/scripts/ci/prek/mypy_folder.py +++ b/scripts/ci/prek/mypy_folder.py @@ -34,6 +34,7 @@ console, get_all_provider_ids, initialize_breeze_prek, + is_hidden_within_root, run_command_via_breeze_run, ) @@ -110,7 +111,7 @@ def get_all_files(folder: str) -> list[str]: for file in python_file_paths: if ( (file.name not in ("conftest.py",) and not any(x.match(file.as_posix()) for x in exclude_regexps)) - and not any(part.startswith(".") for part in file.parts) + and not is_hidden_within_root(file, AIRFLOW_ROOT_PATH) ) and not file.as_posix().endswith("src/airflow/providers/__init__.py"): files_to_check.append(file.relative_to(AIRFLOW_ROOT_PATH).as_posix()) file_spec = "@/files/mypy_files.txt" diff --git a/scripts/ci/prek/run_mypy_full_dist_local_venv_or_breeze_in_ci.py b/scripts/ci/prek/run_mypy_full_dist_local_venv_or_breeze_in_ci.py index ca9f09adae6c7..66b8db86f6ea8 100755 --- a/scripts/ci/prek/run_mypy_full_dist_local_venv_or_breeze_in_ci.py +++ b/scripts/ci/prek/run_mypy_full_dist_local_venv_or_breeze_in_ci.py @@ -40,6 +40,7 @@ from common_prek_utils import ( AIRFLOW_ROOT_PATH, check_uv_version, + is_hidden_within_root, ) CI = os.environ.get("CI") @@ -138,7 +139,7 @@ def get_all_files(folder: str) -> list[str]: if ( file.name not in ("conftest.py",) and not any(x.match(file.as_posix()) for x in exclude_regexps) - and not any(part.startswith(".") for part in file.parts) + and not is_hidden_within_root(file, AIRFLOW_ROOT_PATH) ): files_to_check.append(file.relative_to(AIRFLOW_ROOT_PATH).as_posix()) return files_to_check diff --git a/scripts/tests/ci/prek/test_common_prek_utils.py b/scripts/tests/ci/prek/test_common_prek_utils.py index 9c011f8f53c3c..f28961f900694 100644 --- a/scripts/tests/ci/prek/test_common_prek_utils.py +++ b/scripts/tests/ci/prek/test_common_prek_utils.py @@ -29,6 +29,7 @@ get_provider_id_from_path, initialize_breeze_prek, insert_documentation, + is_hidden_within_root, pre_process_mypy_files, read_airflow_version, read_allowed_kubernetes_versions, @@ -170,6 +171,21 @@ def test_no_default_branch_keeps_providers(self, monkeypatch): assert PROVIDERS_AMAZON_S3_PATH in result +class TestIsHiddenWithinRoot: + @pytest.mark.parametrize( + ("relative_path", "expected"), + [ + ("airflow-core/src/airflow/models/dag.py", False), + (".build/mypy-venvs/foo.py", True), + ("airflow-core/.venv/lib/foo.py", True), + (".hidden.py", True), + ], + ) + def test_only_components_below_root_count(self, tmp_path, relative_path, expected): + root = tmp_path / ".claude" / "worktrees" + assert is_hidden_within_root(root / relative_path, root) is expected + + class TestGetImportsFromFile: def test_simple_import(self, write_python_file): path = write_python_file("import os\nimport sys\n") From e89ed9db506e2644374420b2dfb5b7260909a959 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 19 Jul 2026 05:02:03 +0200 Subject: [PATCH 31/86] Document 3.3.0 image databricks constraint fix in Dockerfile changelog (#70083) Record in the 'Changes after publishing the images' table that the 3.3.0 Python 3.11/3.12 images had their downgraded databricks stack corrected (provider 6.13.0 -> 7.16.1, connector 2.0.2 -> 4.2.5, thrift 0.23.0 -> 0.16.0). related: #69603 --- docker-stack-docs/changelog.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docker-stack-docs/changelog.rst b/docker-stack-docs/changelog.rst index 89024b1c38e99..d900938a36582 100644 --- a/docker-stack-docs/changelog.rst +++ b/docker-stack-docs/changelog.rst @@ -383,6 +383,13 @@ here so that users affected can find the reason for the changes. +--------------+-------------+-----------------------------------------------------------------------------------+-------------------------------------------------------+------------------------------------------------+ | Date | Versions | Potentially breaking change | Reason | Link to Pull Request / Issue | +==============+=============+===================================================================================+=======================================================+================================================+ +| 19 July 2026 | 3.3.0 | * The ``apache-airflow-providers-databricks`` provider was upgraded from 6.13.0 | ``thrift`` 0.23.0 conflicted with | https://github.com/apache/airflow/issues/69603 | +| | | to 7.16.1 in the Python 3.11 and 3.12 images. | ``databricks-sql-connector`` 4.x (which requires | | +| | | * ``databricks-sql-connector`` upgraded from 2.0.2 to 4.2.5 and ``thrift`` from | ``thrift<0.21.0``), so the resolver kept the newer | | +| | | 0.23.0 to 0.16.0. | thrift and downgraded the connector, dragging the | | +| | | | databricks provider back to 6.13.0 (Oct 2024) with | | +| | | | import errors on 3.3.0. | | ++--------------+-------------+-----------------------------------------------------------------------------------+-------------------------------------------------------+------------------------------------------------+ | 28 Feb 2026 | 2.11.1 | * The grpcio==1.78.0 is used instead of 1.78.1 in Python 3.12 images. | The 1.78.1 version was yanked - caused major outage. | https://github.com/grpc/grpc/issues/41725. | +--------------+-------------+-----------------------------------------------------------------------------------+-------------------------------------------------------+------------------------------------------------+ | 23 Feb 2026 | 2.11.1 | * Several dependencies upgraded with minor versions/patchlevels | Virtualenv project pulling out released 20.37.0 | https://github.com/pypa/virtualenv/issues/3061 | From 0dec5d781bcb96ecbc4a0ef30238d176b402d66e Mon Sep 17 00:00:00 2001 From: Guan-Ming Chiu <105915352+guan404ming@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:56:31 +0800 Subject: [PATCH 32/86] Fix DocumentLoaderOperator ignoring unknown parser on byte input (#70071) * Fix DocumentLoaderOperator ignoring unknown parser on byte input * Validate explicit parser in _resolve_backend --- .../airflow/providers/common/ai/operators/document_loader.py | 2 ++ .../tests/unit/common/ai/operators/test_document_loader.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py b/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py index 7f563d0aa4adc..b452cab72edf5 100644 --- a/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py +++ b/providers/common/ai/src/airflow/providers/common/ai/operators/document_loader.py @@ -289,6 +289,8 @@ def _parse_file(self, file_path: Path, ext: str) -> list[dict[str, Any]]: def _resolve_backend(self, ext: str) -> str: if self.parser != "auto": + if self.parser not in set(self.EXTENSION_BACKEND_MAP.values()): + raise ValueError(f"No parser found for backend '{self.parser}'.") return self.parser ext = ext.lower() diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py b/providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py index bb9db83347cf3..6681c0f463dee 100644 --- a/providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py +++ b/providers/common/ai/tests/unit/common/ai/operators/test_document_loader.py @@ -410,6 +410,11 @@ def test_unknown_extension_on_single_file_raises(self, tmp_path): with pytest.raises(ValueError, match="No parser registered"): op.execute(context=MagicMock()) + def test_unknown_parser_on_source_bytes_raises(self): + op = DocumentLoaderOperator(task_id="test", source_bytes=b"a,b\n1,2", file_type=".csv", parser="cvs") + with pytest.raises(ValueError, match="No parser found"): + op.execute(context=MagicMock()) + def test_nonexistent_glob_raises_file_not_found(self, tmp_path): op = DocumentLoaderOperator(task_id="test", source_path=str(tmp_path / "*.nope")) with pytest.raises(FileNotFoundError, match="No files found"): From 8f1ef2fffd207282875f24e2e395fdb0c118919e Mon Sep 17 00:00:00 2001 From: Guan-Ming Chiu <105915352+guan404ming@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:30:12 +0800 Subject: [PATCH 33/86] Send TS SDK logs to stderr on socket disconnect (#69302) * Send TypeScript SDK logs to stderr when the log socket disconnects Co-authored-by: Jason(Zhe-You) Liu <68415893+jason810496@users.noreply.github.com> * Harden TS SDK log channel shutdown * Unref log channel close flush timer so it cannot delay process exit --------- Co-authored-by: Jason(Zhe-You) Liu <68415893+jason810496@users.noreply.github.com> --- ts-sdk/src/coordinator/log-channel.ts | 61 ++++++++-- ts-sdk/tests/coordinator/log-channel.test.ts | 121 ++++++++++++++++++- 2 files changed, 167 insertions(+), 15 deletions(-) diff --git a/ts-sdk/src/coordinator/log-channel.ts b/ts-sdk/src/coordinator/log-channel.ts index e64737e3b671b..dd3d78ea50969 100644 --- a/ts-sdk/src/coordinator/log-channel.ts +++ b/ts-sdk/src/coordinator/log-channel.ts @@ -47,31 +47,48 @@ export interface LogRecord { const DEFAULT_LOGGER_NAME = "ts-sdk"; +const CLOSE_FLUSH_TIMEOUT_MS = 3_000; + +interface LogChannelState { + sock: Socket; + connected: boolean; + closed: boolean; +} + export class LogChannel { - private readonly sock: Socket; + private readonly shared: LogChannelState; private readonly name: string; private readonly isRoot: boolean; - private constructor(sock: Socket, name: string, isRoot: boolean) { - this.sock = sock; + private constructor(shared: LogChannelState, name: string, isRoot: boolean) { + this.shared = shared; this.name = name; this.isRoot = isRoot; - if (isRoot) { - sock.on("error", (err) => { - process.stderr.write(`[${this.name}] log socket error: ${err.message}\n`); - }); - } } static async connect(addr: string, name: string = DEFAULT_LOGGER_NAME): Promise { - return new LogChannel(await connectTcp(addr), name, true); + const shared: LogChannelState = { + sock: await connectTcp(addr), + connected: true, + closed: false, + }; + shared.sock.on("error", (err) => { + shared.connected = false; + process.stderr.write(`[${name}] log socket error: ${err.message}\n`); + }); + shared.sock.on("close", () => { + if (shared.closed || !shared.connected) return; + shared.connected = false; + process.stderr.write(`[${name}] log socket closed unexpectedly; further logs go to stderr\n`); + }); + return new LogChannel(shared, name, true); } /** Create a sibling logger that shares the underlying socket but * carries a hierarchical name (`parent.suffix`). Only the root * owns the socket — children's `close()` is a no-op. */ child(suffix: string): LogChannel { - return new LogChannel(this.sock, `${this.name}.${suffix}`, false); + return new LogChannel(this.shared, `${this.name}.${suffix}`, false); } /** Name reported in the `logger` field of every record this @@ -87,7 +104,7 @@ export class LogChannel { }, ): void { // Drop late records after the log socket has closed. - if (this.sock.writableEnded) return; + if (this.shared.closed) return; // Prepend the logger name to the event message so it surfaces in // the Airflow UI task log view, which renders the message text but // hides the `logger` JSON field. The field is still emitted for @@ -99,7 +116,12 @@ export class LogChannel { event: `[${this.name}] ${record.event}`, timestamp: record.timestamp ?? new Date().toISOString(), }); - this.sock.write(Buffer.from(line + "\n", "utf8")); + const payload = line + "\n"; + if (!this.shared.connected || !this.shared.sock.writable) { + process.stderr.write(payload); + return; + } + this.shared.sock.write(Buffer.from(payload, "utf8")); } debug(event: string, args: Record = {}): void { @@ -120,8 +142,21 @@ export class LogChannel { async close(): Promise { if (!this.isRoot) return; + this.shared.closed = true; + if (!this.shared.connected) { + this.shared.sock.destroy(); + return; + } return new Promise((resolve) => { - this.sock.end(() => resolve()); + const timer = setTimeout(() => { + this.shared.sock.destroy(); + resolve(); + }, CLOSE_FLUSH_TIMEOUT_MS); + timer.unref(); + this.shared.sock.end(() => { + clearTimeout(timer); + resolve(); + }); }); } } diff --git a/ts-sdk/tests/coordinator/log-channel.test.ts b/ts-sdk/tests/coordinator/log-channel.test.ts index 2b8a79579dc4e..ad85c487ce8d1 100644 --- a/ts-sdk/tests/coordinator/log-channel.test.ts +++ b/ts-sdk/tests/coordinator/log-channel.test.ts @@ -135,7 +135,7 @@ describe("LogChannel", () => { const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const root = await LogChannel.connect(`127.0.0.1:${fx.port}`); root.child("child"); - const sock = (root as unknown as { sock: net.Socket }).sock; + const sock = (root as unknown as { shared: { sock: net.Socket } }).shared.sock; try { sock.emit("error", new Error("boom")); @@ -163,10 +163,61 @@ describe("LogChannel", () => { expect(records[0]).toMatchObject({ event: "[ts-sdk] before close" }); }); + it("writes only the error message when error and close fire together", async () => { + const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const root = await LogChannel.connect(`127.0.0.1:${fx.port}`); + const sock = (root as unknown as { shared: { sock: net.Socket } }).shared.sock; + + try { + sock.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" })); + sock.destroy(); + await fx.sockClosed; + expect(write).toHaveBeenCalledTimes(1); + expect(write.mock.calls[0]?.[0]).toBe("[ts-sdk] log socket error: write EPIPE\n"); + } finally { + write.mockRestore(); + await root.close(); + } + }); + + it("falls back to stderr when the socket is no longer writable", async () => { + const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const root = await LogChannel.connect(`127.0.0.1:${fx.port}`); + const sock = (root as unknown as { shared: { sock: net.Socket } }).shared.sock; + + try { + sock.end(); + root.info("peer died"); + const line = write.mock.calls.find((c) => String(c[0]).includes("peer died"))?.[0]; + expect(String(line)).toContain('"event":"[ts-sdk] peer died"'); + } finally { + write.mockRestore(); + await root.close(); + await fx.sockClosed; + } + }); + + it("close() resolves via timeout when the flush never completes", async () => { + const root = await LogChannel.connect(`127.0.0.1:${fx.port}`); + const sock = (root as unknown as { shared: { sock: net.Socket } }).shared.sock; + vi.spyOn(sock, "end").mockImplementation(() => sock); + const destroy = vi.spyOn(sock, "destroy"); + + vi.useFakeTimers(); + try { + const closed = root.close(); + await vi.advanceTimersByTimeAsync(3_000); + await closed; + expect(destroy).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("reports close-time socket errors", async () => { const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const root = await LogChannel.connect(`127.0.0.1:${fx.port}`); - const sock = (root as unknown as { sock: net.Socket }).sock; + const sock = (root as unknown as { shared: { sock: net.Socket } }).shared.sock; try { sock.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" })); @@ -178,4 +229,70 @@ describe("LogChannel", () => { await fx.sockClosed; } }); + + it("warns once and falls back to stderr after an unexpected disconnect", async () => { + const serverSocks: net.Socket[] = []; + const received: Buffer[] = []; + const server = net.createServer((sock) => { + serverSocks.push(sock); + sock.on("data", (chunk) => received.push(chunk)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as net.AddressInfo).port; + + const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const root = await LogChannel.connect(`127.0.0.1:${port}`); + const child = root.child("comm"); + const shared = (root as unknown as { shared: { connected: boolean } }).shared; + try { + root.info("before disconnect"); + await vi.waitFor(() => expect(readRecords(received)).toHaveLength(1)); + + serverSocks[0]!.destroy(); + await vi.waitFor(() => expect(shared.connected).toBe(false)); + expect(write).toHaveBeenCalledWith( + "[ts-sdk] log socket closed unexpectedly; further logs go to stderr\n", + ); + + root.info("while disconnected"); + child.debug("child while disconnected"); + const stderrRecords = write.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.startsWith("{")) + .map((line) => JSON.parse(line) as Record); + expect(stderrRecords.map((r) => r["event"])).toEqual([ + "[ts-sdk] while disconnected", + "[ts-sdk.comm] child while disconnected", + ]); + expect(readRecords(received)).toHaveLength(1); + } finally { + write.mockRestore(); + await root.close(); + server.close(); + } + }); + + it("close() while disconnected resolves and drops later records", async () => { + const serverSocks: net.Socket[] = []; + const server = net.createServer((sock) => serverSocks.push(sock)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as net.AddressInfo).port; + + const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const root = await LogChannel.connect(`127.0.0.1:${port}`); + try { + await vi.waitFor(() => expect(serverSocks).toHaveLength(1)); + serverSocks[0]!.destroy(); + const shared = (root as unknown as { shared: { connected: boolean } }).shared; + await vi.waitFor(() => expect(shared.connected).toBe(false)); + + await root.close(); + write.mockClear(); + root.info("dropped after close"); + expect(write).not.toHaveBeenCalled(); + } finally { + write.mockRestore(); + server.close(); + } + }); }); From b75aeeba81dace73b3bcb0f4fd1414af4f6f00e5 Mon Sep 17 00:00:00 2001 From: "Jason(Zhe-You) Liu" <68415893+jason810496@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:54:32 +0800 Subject: [PATCH 34/86] Build Go SDK e2e example bundle natively in CI (#69909) * Build Go SDK e2e example bundle natively in CI * Capture build output and remove path duplication in Go SDK e2e bundle build The native/containerized build branch was inlined with no output capture, so a failed CI build surfaced only a bare traceback, unlike the Java SDK's helper which prints the build log on failure. The containerized path also hardcoded /repo/go-sdk/bin instead of deriving it from the already-imported GO_SDK_ROOT_PATH/GO_SDK_BIN_PATH constants, and the Go build cache key ignored go.mod so a toolchain bump alone wouldn't invalidate it. --- .github/workflows/airflow-e2e-tests.yml | 52 ++++++-- .../tests/airflow_e2e_tests/conftest.py | 112 ++++++++++++------ .../tests/airflow_e2e_tests/constants.py | 3 +- 3 files changed, 118 insertions(+), 49 deletions(-) diff --git a/.github/workflows/airflow-e2e-tests.yml b/.github/workflows/airflow-e2e-tests.yml index 3799802a5db72..8cb55568e3e5b 100644 --- a/.github/workflows/airflow-e2e-tests.yml +++ b/.github/workflows/airflow-e2e-tests.yml @@ -121,16 +121,17 @@ jobs: use-uv: ${{ inputs.use-uv }} make-mnt-writeable-and-cleanup: true id: breeze - # Only the java_sdk mode runs the Gradle builds; every other e2e mode skips the - # steps below. LANG_SDK_NATIVE_TOOLCHAIN=true (the same switch the lang-SDK k8s - # job uses) makes the e2e conftest build with the host toolchain provisioned - # here instead of ephemeral eclipse-temurin containers, skipping the - # toolchain-image pull; local runs keep the containerized build. Without the - # cache each run re-downloads the example bundles' full dependency tree (Spark - # alone is ~1GB) into a fresh ~/.gradle. The key carries a ``-vN-`` salt (bump - # to force-invalidate a poisoned cache) and runner.arch keys the cache per - # architecture: ~/.gradle/jdks holds arch-specific auto-provisioned JDKs (the - # example pins a Java 11 toolchain) and this job runs on amd64 and arm64. + # The java_sdk mode runs the Gradle builds and the go_sdk mode runs the Go + # build; every other e2e mode skips the toolchain steps below. + # LANG_SDK_NATIVE_TOOLCHAIN=true (the same switch the lang-SDK k8s job uses) + # makes the e2e conftest build with the host toolchain provisioned here + # instead of an ephemeral toolchain container, skipping the image pull; local + # runs keep the containerized builds. The cache keys carry a ``-vN-`` salt + # (bump to force-invalidate a poisoned cache) and runner.arch keys each cache + # per architecture, as this job runs on amd64 and arm64. Without its cache + # each java_sdk run re-downloads the example bundles' full dependency tree + # (Spark alone is ~1GB) into a fresh ~/.gradle, whose ~/.gradle/jdks holds + # arch-specific auto-provisioned JDKs (the example pins a Java 11 toolchain). - name: "Setup Java for the Java SDK build" if: inputs.e2e_test_mode == 'java_sdk' uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 @@ -151,12 +152,32 @@ jobs: key: e2e-java-sdk-gradle-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('java-sdk/**/*.gradle*', 'java-sdk/**/gradle.properties', 'java-sdk/gradle/wrapper/gradle-wrapper.properties', 'java-sdk/gradle/libs.versions.toml') }} # yamllint disable-line rule:line-length restore-keys: | e2e-java-sdk-gradle-v1-${{ runner.os }}-${{ runner.arch }}- + # go-version-file sources the Go toolchain from go-sdk/go.mod, so there is no + # separate version pin to keep in sync; cache: false because the explicit + # restore/save pair below persists the module + build caches even on red runs. + - name: "Setup Go for the Go SDK build" + if: inputs.e2e_test_mode == 'go_sdk' + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go-sdk/go.mod + cache: false + - name: "Restore Go SDK module + build cache" + if: inputs.e2e_test_mode == 'go_sdk' + id: go-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: e2e-go-sdk-go-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go-sdk/go.mod', 'go-sdk/go.sum') }} # yamllint disable-line rule:line-length + restore-keys: | + e2e-go-sdk-go-v1-${{ runner.os }}-${{ runner.arch }}- - name: "Test e2e integration tests" run: breeze testing airflow-e2e-tests env: DOCKER_IMAGE: "${{ inputs.docker-image-tag }}" E2E_TEST_MODE: "${{ inputs.e2e_test_mode }}" - LANG_SDK_NATIVE_TOOLCHAIN: "${{ inputs.e2e_test_mode == 'java_sdk' && 'true' || 'false' }}" + LANG_SDK_NATIVE_TOOLCHAIN: "${{ (inputs.e2e_test_mode == 'java_sdk' || inputs.e2e_test_mode == 'go_sdk') && 'true' || 'false' }}" # yamllint disable-line rule:line-length - name: "Save Java SDK Gradle dependency cache" # Saved even when the e2e tests fail: the Gradle warm-up is independent of # test outcome, and actions/cache's post step would drop it on every red run. @@ -170,6 +191,15 @@ jobs: !~/.gradle/caches/*.lock !~/.gradle/caches/journal-1 key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + - name: "Save Go SDK module + build cache" + # Saved even when the e2e tests fail, for the same reason as the Gradle cache above. + if: always() && inputs.e2e_test_mode == 'go_sdk' && steps.go-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ steps.go-cache.outputs.cache-primary-key }} - name: Zip logs run: | cd ./airflow-e2e-tests && zip -r logs.zip logs diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py index 56b51992bb4cb..72687795ae1f9 100644 --- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py +++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py @@ -44,6 +44,7 @@ GO_SDK_BUNDLE_NAME, GO_SDK_DAGS_PATH, GO_SDK_EXAMPLE_BUNDLE_PKG, + GO_SDK_ROOT_PATH, JAVA_COMPOSE_PATH, JAVA_DOCKERFILE_PATH, JAVA_SDK_EXAMPLE_DAGS_PATH, @@ -487,65 +488,102 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir): os.environ["ENV_FILE_PATH"] = str(dot_env_file) -def _setup_go_sdk_integration(dot_env_file, tmp_dir): - """Set up the go_sdk E2E test mode. +def _run_go_sdk_pack(output_path, *, capture_output=False, native=False): + """Run ``go tool airflow-go-pack`` natively or inside the pinned Go toolchain container. - Compiles the Go SDK example bundle into a self-contained executable bundle - via the ``airflow-go-pack`` tooling, drops it into the directory the - ``ExecutableCoordinator`` scans, copies the Python stub Dag, and writes the - coordinator configuration. + ``go tool airflow-go-pack`` builds the bundle package, reads its + --airflow-metadata, and appends the source + airflow-metadata.yaml + the + AFBNDL01 trailer, writing a single self-contained executable bundle. + CGO_ENABLED=0 yields a fully static binary that runs on the stock worker. - The packed bundle is a statically linked native executable (built with - ``CGO_ENABLED=0``), so the stock Airflow worker image can exec it directly - without a Go toolchain or any extra runtime installed -- see ``go.yml``. + In ``native`` mode (used in CI, where the host already has a Go toolchain plus + restored module/build caches via ``actions/setup-go``) it invokes the host ``go`` + directly, skipping the toolchain-image pull and the container workarounds below. + + The containerized path stays the default for local runs so a dev host needs + no Go installed: + + * --user keeps build outputs owned by the current user (not root). + * HOME points at a writable, gitignored dir under go-sdk/bin so the Go build + and module caches persist between runs (first run downloads modules once; + subsequent runs skip straight to compilation). + * USER/HOME must be set because the SDK calls user.Current() at init; with + cgo disabled Go's pure-Go resolver reads those env vars instead of libc, + and panics if either is empty (the same vars are set on the worker in + go.yml so the packed binary runs the same way at execution time). """ - # Build + pack the example bundle inside an ephemeral Go container so the - # host does not need Go installed. - # - # --user keeps build outputs owned by the current user (not root). - # HOME points at a writable, gitignored dir under go-sdk/bin so the Go build - # and module caches persist between runs (first run downloads modules once; - # subsequent runs skip straight to compilation). - # CGO_ENABLED=0 yields a fully static binary that runs on the stock worker. - # USER/HOME must be set because the SDK calls user.Current() at init; with - # cgo disabled Go's pure-Go resolver reads those env vars instead of libc, - # and panics if either is empty (the same vars are set on the worker in - # go.yml so the packed binary runs the same way at execution time). - # `go tool airflow-go-pack` builds the bundle package, reads its - # --airflow-metadata, and appends the source + airflow-metadata.yaml + the - # AFBNDL01 trailer, writing a single self-contained executable bundle. - go_cache_home = "/repo/go-sdk/bin/.home" - bundle_out = f"/repo/go-sdk/bin/{GO_SDK_BUNDLE_NAME}" - console.print(f"[yellow]Building Go SDK example bundle ({GO_BUILDER_IMAGE})...") - subprocess.run( - [ + if native: + cwd = GO_SDK_ROOT_PATH + env = {**os.environ, "CGO_ENABLED": "0"} + argv = [ + "go", + "tool", + "airflow-go-pack", + "--output", + str(output_path), + GO_SDK_EXAMPLE_BUNDLE_PKG, + ] + else: + cwd = None + env = None + # Mount the repo so the whole go-sdk module (go.mod, tool directive, + # example sources) is visible to `go tool`. + container_go_sdk_dir = f"/repo/{GO_SDK_ROOT_PATH.relative_to(AIRFLOW_ROOT_PATH)}" + container_bin_dir = f"/repo/{GO_SDK_BIN_PATH.relative_to(AIRFLOW_ROOT_PATH)}" + argv = [ "docker", "run", "--rm", "--user", f"{os.getuid()}:{os.getgid()}", "-e", - f"HOME={go_cache_home}", + f"HOME={container_bin_dir}/.home", "-e", "USER=airflow", "-e", "CGO_ENABLED=0", - # Mount the repo so the whole go-sdk module (go.mod, tool directive, - # example sources) is visible to `go tool`. "-v", f"{AIRFLOW_ROOT_PATH}:/repo", "-w", - "/repo/go-sdk", + container_go_sdk_dir, GO_BUILDER_IMAGE, "go", "tool", "airflow-go-pack", "--output", - bundle_out, + f"{container_bin_dir}/{output_path.name}", GO_SDK_EXAMPLE_BUNDLE_PKG, - ], - check=True, - ) + ] + return subprocess.run(argv, cwd=cwd, env=env, check=True, capture_output=capture_output, text=True) + + +def _pack_go_sdk_example_bundle(*, native=False): + """Build the Go SDK example bundle, capturing output so a failure prints the build log.""" + output_path = GO_SDK_BIN_PATH / GO_SDK_BUNDLE_NAME + mode_label = "host toolchain" if native else GO_BUILDER_IMAGE + console.print(f"[yellow]Building Go SDK example bundle ({mode_label})...") + try: + completed = _run_go_sdk_pack(output_path, capture_output=True, native=native) + except subprocess.CalledProcessError as e: + console.print("[red]Go SDK example bundle build failed:") + console.print(e.stdout, e.stderr, sep="\n", markup=False, soft_wrap=True) + raise + console.print(completed.stdout, completed.stderr, sep="\n", markup=False, soft_wrap=True) + + +def _setup_go_sdk_integration(dot_env_file, tmp_dir): + """Set up the go_sdk E2E test mode. + + Compiles the Go SDK example bundle into a self-contained executable bundle + via the ``airflow-go-pack`` tooling, drops it into the directory the + ``ExecutableCoordinator`` scans, copies the Python stub Dag, and writes the + coordinator configuration. + + The packed bundle is a statically linked native executable (built with + ``CGO_ENABLED=0``), so the stock Airflow worker image can exec it directly + without a Go toolchain or any extra runtime installed -- see ``go.yml``. + """ + _pack_go_sdk_example_bundle(native=LANG_SDK_NATIVE_TOOLCHAIN) # Copy the compose override into the temp directory. copyfile(GO_COMPOSE_PATH, tmp_dir / "go.yml") diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py index ec1732ac75a8b..6b8d7c42d0c83 100644 --- a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py +++ b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py @@ -83,7 +83,8 @@ # Where airflow-go-pack writes the packed bundle inside the repo (go-sdk/bin is gitignored). GO_SDK_BIN_PATH = GO_SDK_ROOT_PATH / "bin" GO_COMPOSE_PATH = AIRFLOW_ROOT_PATH / "airflow-e2e-tests" / "docker" / "go.yml" -# Go toolchain image used to build the bundle; must satisfy go-sdk/go.mod's toolchain. +# Go toolchain image used to build the bundle in the containerized path (i.e. unless +# LANG_SDK_NATIVE_TOOLCHAIN is set); must satisfy go-sdk/go.mod's toolchain. # The Alpine variant is ~7x smaller than the Debian one and is safe here because the # bundle is built with CGO_ENABLED=0 (a fully static binary, independent of musl/glibc) # and module fetches go through the HTTPS proxy (no git/gcc needed). From 048312aa56c80861c75ddd9695b2e9af00b29fd6 Mon Sep 17 00:00:00 2001 From: Hojeong Park Date: Sun, 19 Jul 2026 18:35:42 +0900 Subject: [PATCH 35/86] i18n(ko): translate Dag list run-state filter labels (#69781) --- airflow-core/src/airflow/ui/public/i18n/locales/ko/dags.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ko/dags.json b/airflow-core/src/airflow/ui/public/i18n/locales/ko/dags.json index 63ef55452a27a..01c630bce5764 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/ko/dags.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/ko/dags.json @@ -10,11 +10,13 @@ "filters": { "allRunTypes": "모든 실행 유형", "allStates": "모든 상태", + "anyRunState": "하나 이상의 실행", "favorite": { "all": "전체", "favorite": "즐겨찾기", "unfavorite": "즐겨찾기 해제" }, + "lastRunState": "마지막 실행", "paused": { "active": "활성", "all": "모두", From 3bc203d244930744215ff32d4597d71fe12829c2 Mon Sep 17 00:00:00 2001 From: Guan-Ming Chiu <105915352+guan404ming@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:51:31 +0800 Subject: [PATCH 36/86] Preserve output_type through human approval in LLM operators (#70075) * Preserve output_type through human approval in LLM operators * Cover BaseModel in output_type restore test --- .../providers/common/ai/mixins/approval.py | 8 ++++---- .../providers/common/ai/utils/output_type.py | 20 +++++++++---------- .../unit/common/ai/mixins/test_approval.py | 13 ++++++++---- .../unit/common/ai/operators/test_llm.py | 19 ++++++++++++++++++ .../unit/common/ai/utils/test_output_type.py | 15 +++++++++++++- 5 files changed, 56 insertions(+), 19 deletions(-) diff --git a/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py b/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py index 5ebd679efcdcf..9f045c0254888 100644 --- a/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py +++ b/providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py @@ -21,7 +21,7 @@ from datetime import timedelta from typing import TYPE_CHECKING, Any, Protocol -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter from airflow.providers.common.compat.version_compat import AIRFLOW_V_3_3_PLUS @@ -107,9 +107,9 @@ def defer_for_approval( if isinstance(output, BaseModel): output = output.model_dump_json() - if not isinstance(output, str): - # Always make string output so that when comparing in the execute_complete matches - output = str(output) + elif not isinstance(output, str): + # JSON round-trip: execute_complete validates the string back into output_type. + output = TypeAdapter(type(output)).dump_json(output).decode() ti_id = context["task_instance"].id diff --git a/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py b/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py index 2ae9b6a5bc302..44ca8505b5404 100644 --- a/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py +++ b/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py @@ -20,7 +20,7 @@ from typing import Any -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError def rehydrate_pydantic_output( @@ -30,25 +30,25 @@ def rehydrate_pydantic_output( serialize_output: bool, ) -> Any: """ - Turn a JSON string back into the ``output_type`` Pydantic model. + Turn a JSON string back into a value of ``output_type``. Used by the HITL/approval paths in ``LLMOperator`` and ``AgentOperator`` - that round-trip the model through a string when deferring to a human - reviewer. When ``output_type`` is not a ``BaseModel`` subclass, returns - ``raw`` unchanged so the caller can apply its own fallback (e.g. - ``json.loads``). When validation fails (reviewer edited the string into - something the schema rejects), also returns ``raw`` unchanged. + that round-trip the output through a string when deferring to a human + reviewer. ``str`` outputs pass through unchanged; any other ``output_type`` + (``BaseModel`` subclass, ``int``, ``list[str]``, ...) is validated with a + pydantic ``TypeAdapter``. When validation fails (reviewer edited the string + into something the type rejects), returns ``raw`` unchanged. When ``serialize_output`` is ``True``, returns the model dumped to a ``dict`` -- matches the operator's ``serialize_output=True`` opt-in for consumers that want the dict shape. """ - if not (isinstance(output_type, type) and issubclass(output_type, BaseModel)): + if output_type is str: return raw try: - rehydrated = output_type.model_validate_json(raw) + rehydrated = TypeAdapter(output_type).validate_json(raw) except (ValidationError, ValueError, TypeError): return raw - if serialize_output: + if serialize_output and isinstance(rehydrated, BaseModel): return rehydrated.model_dump() return rehydrated diff --git a/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py b/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py index 54b675da723a9..46120b3c3c31f 100644 --- a/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py +++ b/providers/common/ai/tests/unit/common/ai/mixins/test_approval.py @@ -118,15 +118,20 @@ class Answer(BaseModel): defer_kwargs = approval_op.defer.call_args[1] assert defer_kwargs["kwargs"]["generated_output"] == '{"text":"Paris","confidence":0.95}' + @pytest.mark.parametrize( + ("output", "expected"), + [(42, "42"), (True, "true"), ([1, "a"], '[1,"a"]'), ({"k": "v"}, '{"k":"v"}')], + ids=["int", "bool", "list", "dict"], + ) @patch(HITL_TRIGGER_PATH, autospec=True) @patch(UPSERT_HITL_PATH) - def test_non_string_non_pydantic_output_is_stringified( - self, mock_upsert, mock_trigger_cls, approval_op, context + def test_non_string_non_pydantic_output_is_json_encoded( + self, mock_upsert, mock_trigger_cls, approval_op, context, output, expected ): - approval_op.defer_for_approval(context, 42) + approval_op.defer_for_approval(context, output) defer_kwargs = approval_op.defer.call_args[1] - assert defer_kwargs["kwargs"]["generated_output"] == "42" + assert defer_kwargs["kwargs"]["generated_output"] == expected @patch(HITL_TRIGGER_PATH, autospec=True) @patch(UPSERT_HITL_PATH) diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_llm.py b/providers/common/ai/tests/unit/common/ai/operators/test_llm.py index f9f3bf0909924..e2004b4031c32 100644 --- a/providers/common/ai/tests/unit/common/ai/operators/test_llm.py +++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm.py @@ -379,6 +379,25 @@ def test_execute_complete_rehydrates_pydantic_for_structured_output(self): assert isinstance(result, Summary) assert result.text == "hello" + @pytest.mark.parametrize( + ("output_type", "generated_output", "expected"), + [ + (int, "5", 5), + (list[str], '["a","b"]', ["a", "b"]), + pytest.param(Summary, '{"text":"hello"}', Summary(text="hello"), marks=requires_typed_xcom), + ], + ids=["int", "list", "basemodel"], + ) + def test_execute_complete_restores_non_str_output_type(self, output_type, generated_output, expected): + op = LLMOperator( + task_id="t", prompt="p", llm_conn_id="c", output_type=output_type, require_approval=True + ) + event = {"chosen_options": ["Approve"], "responded_by_user": "admin"} + + result = op.execute_complete({}, generated_output=generated_output, event=event) + + assert result == expected + @pytest.mark.skipif( not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible with Airflow >= 3.1.0" diff --git a/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py b/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py index 4c3dcae5ee273..f7e1a4799e269 100644 --- a/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py +++ b/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py @@ -16,6 +16,7 @@ # under the License. from __future__ import annotations +import pytest from pydantic import BaseModel from airflow.providers.common.ai.utils.output_type import rehydrate_pydantic_output @@ -35,10 +36,22 @@ def test_returns_dict_when_serialize_output(self): result = rehydrate_pydantic_output(A, '{"x": 7}', serialize_output=True) assert result == {"x": 7} - def test_returns_raw_for_non_basemodel(self): + def test_returns_raw_for_str_output_type(self): result = rehydrate_pydantic_output(str, "anything", serialize_output=False) assert result == "anything" + @pytest.mark.parametrize( + ("output_type", "raw", "expected"), + [(int, "5", 5), (bool, "true", True), (list[str], '["a", "b"]', ["a", "b"])], + ids=["int", "bool", "list"], + ) + def test_validates_other_types_with_type_adapter(self, output_type, raw, expected): + assert rehydrate_pydantic_output(output_type, raw, serialize_output=False) == expected + + def test_returns_raw_when_type_adapter_rejects(self): + result = rehydrate_pydantic_output(int, "not-a-number", serialize_output=False) + assert result == "not-a-number" + def test_returns_raw_on_invalid_json(self): result = rehydrate_pydantic_output(A, "not-json", serialize_output=False) assert result == "not-json" From 16ba39382d0105377b633d925ee185372710d8a1 Mon Sep 17 00:00:00 2001 From: Aaron Chen Date: Sun, 19 Jul 2026 22:53:56 +0800 Subject: [PATCH 37/86] Clarify the dags reserialize command scope (#69810) --- airflow-core/docs/howto/usage-cli.rst | 16 ++++++++++++++++ docs/spelling_wordlist.txt | 1 + 2 files changed, 17 insertions(+) diff --git a/airflow-core/docs/howto/usage-cli.rst b/airflow-core/docs/howto/usage-cli.rst index fbecf38dd14a3..d96cf5331f95f 100644 --- a/airflow-core/docs/howto/usage-cli.rst +++ b/airflow-core/docs/howto/usage-cli.rst @@ -334,6 +334,22 @@ For a mapping between Airflow version and Alembic revision see :doc:`/migrations It's highly recommended that you reserialize your Dags with ``dags reserialize`` after you finish downgrading your Airflow environment (meaning, after you've downgraded the Airflow version installed in your Python environment, not immediately after you've downgraded the database). This is to ensure that the serialized Dags are compatible with the downgraded version of Airflow. +.. _cli-reserialize-dags: + +Reserializing Dags +------------------ + +The ``dags reserialize`` command parses the Dag files visible to it and updates their +serialized representation in the metadata database. It is a maintenance command, useful +for example to refresh serialized Dags after upgrading or downgrading Airflow. + +.. note:: + + ``airflow dags reserialize`` serializes the Dag files visible to the process + running it. It does not deploy or synchronize Dag source files, and does not + replace normal Dag Processor bundle refreshes. In a distributed deployment, + run it from an environment that sees the same Dag bundle contents as the Dag Processor. + .. _cli-export-connections: Exporting Connections diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index f9c2156a29ebb..b2583212f4188 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -1424,6 +1424,7 @@ requeue requeued requeues reserialize +Reserializing resetdb resizable ResourceRequirements From 1d1a1a1872c7ca9f7ff1f86ce774842f53979d95 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 19 Jul 2026 18:46:35 +0200 Subject: [PATCH 38/86] Add Task SDK, Go and Java SDK execution architecture diagrams (#69750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Task SDK, Go and Java SDK execution architecture diagrams Add graphviz-generated diagrams to the architecture overview showing how a task actually runs across the different SDKs, and embed them in core-concepts/overview.rst: - Python Task SDK: supervised (two native OS processes over a msgpack socket) vs native/in-process paths, plus a numbered message-flow sequence. - Go SDK: standalone edge worker pulling from the Edge API, task talks to the Execution API directly. - Java (JVM) SDK: Coordinator layer reusing the Python Supervisor, driving a JVM subprocess over loopback TCP, with an architecture diagram and a numbered execution-workflow sequence. * Draw Task and Java SDK execution flows as true sequence diagrams The message-flow diagrams added earlier in this PR drew every step on a single vertical spine, so the request/response round-trips between the task runtime and the Supervisor were hard to follow. Responding to review feedback, redraw them as true sequence diagrams — one lifeline per participant with the Supervisor in the middle — so each round-trip reads as arrows going back and forth between neighboring lifelines, with straight message arrows and margined labels. --- airflow-core/docs/core-concepts/overview.rst | 85 +++++ ...ram_java_sdk_execution_architecture.md5sum | 1 + ...iagram_java_sdk_execution_architecture.png | Bin 0 -> 176205 bytes ...diagram_java_sdk_execution_architecture.py | 287 +++++++++++++++ ...diagram_java_sdk_execution_sequence.md5sum | 1 + .../diagram_java_sdk_execution_sequence.png | Bin 0 -> 189307 bytes .../diagram_java_sdk_execution_sequence.py | 327 ++++++++++++++++++ ...am_native_language_sdk_architecture.md5sum | 1 + ...agram_native_language_sdk_architecture.png | Bin 0 -> 146251 bytes ...iagram_native_language_sdk_architecture.py | 258 ++++++++++++++ ...ram_task_sdk_execution_architecture.md5sum | 1 + ...iagram_task_sdk_execution_architecture.png | Bin 0 -> 184731 bytes ...diagram_task_sdk_execution_architecture.py | 291 ++++++++++++++++ ...diagram_task_sdk_execution_sequence.md5sum | 1 + .../diagram_task_sdk_execution_sequence.png | Bin 0 -> 144211 bytes .../diagram_task_sdk_execution_sequence.py | 313 +++++++++++++++++ docs/spelling_wordlist.txt | 2 + 17 files changed, 1568 insertions(+) create mode 100644 airflow-core/docs/img/diagram_java_sdk_execution_architecture.md5sum create mode 100644 airflow-core/docs/img/diagram_java_sdk_execution_architecture.png create mode 100644 airflow-core/docs/img/diagram_java_sdk_execution_architecture.py create mode 100644 airflow-core/docs/img/diagram_java_sdk_execution_sequence.md5sum create mode 100644 airflow-core/docs/img/diagram_java_sdk_execution_sequence.png create mode 100644 airflow-core/docs/img/diagram_java_sdk_execution_sequence.py create mode 100644 airflow-core/docs/img/diagram_native_language_sdk_architecture.md5sum create mode 100644 airflow-core/docs/img/diagram_native_language_sdk_architecture.png create mode 100644 airflow-core/docs/img/diagram_native_language_sdk_architecture.py create mode 100644 airflow-core/docs/img/diagram_task_sdk_execution_architecture.md5sum create mode 100644 airflow-core/docs/img/diagram_task_sdk_execution_architecture.png create mode 100644 airflow-core/docs/img/diagram_task_sdk_execution_architecture.py create mode 100644 airflow-core/docs/img/diagram_task_sdk_execution_sequence.md5sum create mode 100644 airflow-core/docs/img/diagram_task_sdk_execution_sequence.png create mode 100644 airflow-core/docs/img/diagram_task_sdk_execution_sequence.py diff --git a/airflow-core/docs/core-concepts/overview.rst b/airflow-core/docs/core-concepts/overview.rst index ab12b6afdfd6f..1f2b1ad8a11bf 100644 --- a/airflow-core/docs/core-concepts/overview.rst +++ b/airflow-core/docs/core-concepts/overview.rst @@ -192,6 +192,91 @@ code is never executed in the context of the *scheduler*. bundle version when dispatching each task. If needed, the cadence of sync and scan of the *Dag bundle* can be configured. +Task execution architecture +--------------------------- + +The diagrams above show how Airflow's components are *deployed*. The diagrams below instead show what happens +*inside a worker when a task actually runs* — how the Task SDK, the Supervisor and Coordinator processes, and +the language runtimes work together: which processes are involved, and the classes and protocols they use to +communicate. + +.. _overview-task-sdk-execution-architecture: + +Python Task SDK execution +......................... + +When a *worker* actually runs a task, it does not run the user's code directly. Instead it starts a +lightweight **Supervisor** that runs in its own **native operating-system process** and +*forks* a second native process in which the **Task SDK** runtime (``task_runner``) executes the user code. +The two processes talk over a socket, and the Supervisor is the only side that ever holds the short-lived +task JWT or talks to the *Execution API* — the user's code never sees the token and never touches the +database. + +The same runtime can also run *in-process* (a single Python process, no fork, no sockets, no HTTP) for +``dag.test()`` and local runs. The diagram below contrasts the two paths and marks where each Python process +lives: + +.. image:: ../img/diagram_task_sdk_execution_architecture.png + +The message flow of a supervised run — startup, running the user code, proxied Connection/Variable/XCom +lookups, heartbeats, and reporting the final state — is shown below as a sequence diagram, with each process +on its own lifeline. The **Supervisor** sits in the middle, so the Task ↔ Supervisor request/response +round-trip (the task asks for a Connection/Variable/XCom and gets the answer back) reads as arrows going back +and forth between neighboring lifelines. Each arrow is numbered, colored by its sender, and labeled with the +message class or protocol used: + +.. image:: ../img/diagram_task_sdk_execution_sequence.png + +.. _overview-non-python-language-sdks: + +Non-Python language SDKs (Go and Java) +...................................... + +The Task Execution Interface (TEI) introduced in AIP-72 is language-agnostic, so a task can also be written in +a **compiled, non-Python language**. A Python Dag still declares the task with ``@task.stub(queue=...)`` (so +Python and non-Python tasks can be mixed in one Dag), but the actual work is delegated to the matching runtime. +There are currently **two different integration styles** — the Go SDK runs a standalone worker, while the Java +SDK plugs into the existing Python Supervisor. + +.. _overview-go-sdk-architecture: + +**Go SDK — standalone edge worker.** The `Go Task SDK +`_ has **no Python Supervisor and no msgpack +stdin socket**. A long-running, compiled **edge worker** (``airflow-go-edge-worker``) *pulls* work from the +**Edge Executor API**, launches the user's compiled Dag bundle as a **go-plugin (gRPC) subprocess**, and +invokes the task over gRPC. The task then uses the **native TEI client** to reach the **Execution API** +directly over HTTPS — so, unlike the Python task, it holds the task JWT itself: + +.. image:: ../img/diagram_native_language_sdk_architecture.png + +.. _overview-java-sdk-architecture: + +**Java (JVM) SDK — Coordinator plugged into the Supervisor.** The `Java Task SDK +`_ takes the opposite approach: it *reuses* the +existing Python Supervisor through a new **Coordinator** layer. ``CoordinatorManager`` resolves the task's +``queue`` to a ``BaseCoordinator`` — ``JavaCoordinator`` for the ``java`` queue, or the built-in +``_PythonCoordinator`` otherwise. ``JavaCoordinator`` opens two loopback-TCP servers, spawns a **JVM bundle +process** with ``subprocess.Popen``, and drives it with ``_JavaActivitySubprocess`` (a subclass of the shared +``ActivitySubprocess``). The JVM connects *back* over TCP and speaks the **same msgpack protocol** as a Python +task, so the Python side heartbeats, manages state, and **proxies every Execution-API call** — meaning the JVM +task, like a Python task, never holds the task JWT itself: + +.. image:: ../img/diagram_java_sdk_execution_architecture.png + +The end-to-end workflow of a Java task — from ``@task.stub`` through the coordinator, the JVM subprocess, the +proxied Connection/Variable/XCom lookups, and reporting the final state — is shown below as a sequence diagram. +As above, the **Supervisor** is the central lifeline, so the JVM ↔ Supervisor round-trip over loopback TCP is +drawn as arrows going back and forth to its neighbours: + +.. image:: ../img/diagram_java_sdk_execution_sequence.png + +.. note:: + + Both the Go and Java SDKs are **experimental** and under active development. See the `Go Task SDK + documentation `_ and the `Java Task SDK + documentation `_ for current status, + quick-starts, and known limitations. + .. _overview:workloads: Workloads diff --git a/airflow-core/docs/img/diagram_java_sdk_execution_architecture.md5sum b/airflow-core/docs/img/diagram_java_sdk_execution_architecture.md5sum new file mode 100644 index 0000000000000..377507bd88454 --- /dev/null +++ b/airflow-core/docs/img/diagram_java_sdk_execution_architecture.md5sum @@ -0,0 +1 @@ +061e71105ed4ce16e740a942073b7ace diff --git a/airflow-core/docs/img/diagram_java_sdk_execution_architecture.png b/airflow-core/docs/img/diagram_java_sdk_execution_architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..8fa1e5e6255b6d8abceff121c29b42e53cc4c626 GIT binary patch literal 176205 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 VWqn>e|NYSAl%Q&Ig%3<#{}=13.6.0", +# "graphviz>=0.20.1", +# ] +# /// +""" +Architecture diagram for the Java (JVM) Task SDK. + +Unlike the Go SDK (a standalone edge worker), the Java SDK plugs into the *same* +Python Supervisor via a new **Coordinator** layer: + +* ``CoordinatorManager`` resolves the task's ``queue`` to a ``BaseCoordinator`` + (``JavaCoordinator`` for the ``java`` queue, ``_PythonCoordinator`` otherwise); +* ``JavaCoordinator.execute_task()`` opens two loopback-TCP servers, spawns a JVM + bundle process with ``subprocess.Popen``, and drives it with + ``_JavaActivitySubprocess`` — a subclass of the shared ``ActivitySubprocess``; +* the JVM process connects *back* over TCP and speaks the same msgpack protocol as + a Python task, so the Python side heartbeats, proxies every Execution-API call, + and manages state. The JVM task therefore **never holds the task JWT**. + +Rendered with graphviz directly so labels sit inside sized shapes: 3-D box = +native OS process, rounded box = an object inside a process, component = a server +app, cylinder = the database, note = a caption. +""" + +from __future__ import annotations + +from pathlib import Path + +import graphviz +from rich.console import Console + +MY_DIR = Path(__file__).parent +MY_FILENAME = Path(__file__).with_suffix("").name + +console = Console(width=400, color_system="standard") + +# (fill, border) per role — consistent with the other Task SDK diagrams. +COORD = ("#ede7f6", "#5e35b1") # Coordinator layer (Python, deep purple) +SUP = ("#e3f2fd", "#1565c0") # Supervisor / ActivitySubprocess + Client (blue) +JVM = ("#fbe9e7", "#d84315") # JVM SDK runtime (deep orange) +USER = ("#e8f5e9", "#2e7d32") # user task code (green) +API = ("#fdecea", "#c62828") # Execution API (red) +NEUTRAL = ("#eceff1", "#455a64") # database +NOTE = ("#fffde7", "#f9a825") # caption + + +def _label(title: str, sub: str | None = None) -> str: + html = f"<{title}" + if sub: + html += f'
{sub}' + return html + ">" + + +def _node(g, node_id: str, title: str, sub: str, *, shape: str, theme: tuple[str, str]) -> None: + fill, border = theme + style = "filled" if shape in ("box3d", "cylinder", "component", "note") else "rounded,filled" + g.node( + node_id, + label=_label(title, sub), + shape=shape, + style=style, + fillcolor=fill, + color=border, + penwidth="2", + margin="0.18,0.12", + ) + + +def generate_java_sdk_execution_architecture_diagram(): + image_file = MY_DIR / f"{MY_FILENAME}.png" + console.print(f"[bright_blue]Generating architecture image {image_file}") + + g = graphviz.Digraph("java_sdk_execution_architecture") + g.attr( + rankdir="TB", + splines="spline", + nodesep="0.6", + ranksep="0.9", + pad="0.5", + bgcolor="white", + fontname="Helvetica", + newrank="true", + compound="true", + ) + g.attr("node", fontname="Helvetica", fontsize="13", fontcolor="#102027") + g.attr("edge", fontname="Helvetica", fontsize="10", penwidth="1.8", color="#546e7a") + + # ------------------------------------------------------------------ # + # Worker host — native OS processes. + # ------------------------------------------------------------------ # + with g.subgraph(name="cluster_host") as host: + host.attr( + label="Worker host — native OS processes", + labelloc="t", + style="rounded,filled", + fillcolor="#f7f7fb", + color="#90a4ae", + penwidth="1.5", + fontsize="16", + fontname="Helvetica-Bold", + margin="18", + ) + + with host.subgraph(name="cluster_sup") as sup: + sup.attr( + label="Supervisor process · native OS process (Python)", + labelloc="t", + style="rounded,filled", + fillcolor="#eef0fb", + color=SUP[1], + penwidth="1.5", + fontsize="13", + fontname="Helvetica-Bold", + margin="14", + ) + _node( + sup, + "coord_mgr", + "CoordinatorManager.for_queue()", + "resolves the task queue → a coordinator via
[sdk] queue_to_coordinator / [sdk] coordinators", + shape="box", + theme=COORD, + ) + _node( + sup, + "java_coord", + "JavaCoordinator (BaseCoordinator)", + "execute_task(client): open two loopback-TCP
servers, subprocess.Popen(java -jar bundle)", + shape="box", + theme=COORD, + ) + _node( + sup, + "supervisor", + "_JavaActivitySubprocess", + "subclass of the shared ActivitySubprocess
heartbeat · proxy every API call · manage state", + shape="box3d", + theme=SUP, + ) + _node( + sup, + "client", + "Client", + "authenticated Execution API client
holds the short-lived task JWT", + shape="box", + theme=SUP, + ) + sup.edge( + "coord_mgr", "java_coord", style="dotted", color=COORD[1], arrowhead="vee", label="picks" + ) + sup.edge( + "java_coord", "supervisor", style="dotted", color=SUP[1], arrowhead="vee", label="drives" + ) + sup.edge("supervisor", "client", style="dotted", color=SUP[1], arrowhead="none") + + with host.subgraph(name="cluster_jvm") as jvm: + jvm.attr( + label="JVM bundle subprocess · native OS process (JVM) · runs USER CODE", + labelloc="t", + style="rounded,filled", + fillcolor="#fdefe9", + color=JVM[1], + penwidth="1.5", + fontsize="13", + fontname="Helvetica-Bold", + margin="14", + ) + _node( + jvm, + "server", + "Server.serve(bundle)", + "bundle JAR entry point (Main-Class)
Kotlin runtime · connects back over TCP", + shape="box3d", + theme=JVM, + ) + _node( + jvm, + "task", + "Task.execute(Context, Client)", + "your Java / Kotlin task [user code]
Context = static run data · Client = API accessors", + shape="box3d", + theme=USER, + ) + _node( + jvm, + "comm", + "CoordinatorComm / Frame", + "msgpack framing (Kotlin)", + shape="box", + theme=JVM, + ) + jvm.edge("server", "task", style="dotted", color=JVM[1], arrowhead="vee", label="invokes") + jvm.edge("task", "comm", style="dotted", color=JVM[1], arrowhead="none") + + # Loopback-TCP comms between the two native OS processes (JVM connects back). + g.edge( + "comm", + "supervisor", + label="msgpack frames over loopback TCP (127.0.0.1)\nJVM connects back · ToSupervisor / ToTask", + color=JVM[1], + fontcolor=JVM[1], + dir="both", + penwidth="2.2", + ) + g.edge( + "comm", + "supervisor", + label="structured logs\n(second TCP channel)", + color=JVM[1], + fontcolor="#a1674f", + style="dashed", + ) + + # ------------------------------------------------------------------ # + # API server — separate process / host. + # ------------------------------------------------------------------ # + with g.subgraph(name="cluster_server") as server: + server.attr( + label="API server · separate process / host", + labelloc="t", + style="rounded,filled", + fillcolor="#fdeeec", + color=API[1], + penwidth="1.5", + fontsize="13", + fontname="Helvetica-Bold", + margin="14", + ) + _node( + server, + "execution_api", + "Execution API", + "FastAPI · TEI / AIP-72", + shape="component", + theme=API, + ) + _node(server, "metadata_db", "Metadata DB", "", shape="cylinder", theme=NEUTRAL) + server.edge("execution_api", "metadata_db", style="dotted", color=API[1], dir="both") + + g.edge( + "client", + "execution_api", + label="HTTPS + task JWT\n(proxied for the JVM task)", + color=API[1], + fontcolor=API[1], + penwidth="2.2", + ) + + # Contrast caption. + _node( + g, + "note", + "How it differs", + "vs Python task: same Supervisor, but a JVM subprocess over loopback TCP
" + "instead of a forked Python process over a UNIX socketpair
" + "vs Go SDK: the JVM task does not hold the JWT and does not call the
" + "Execution API directly — the Python Supervisor proxies every call", + shape="note", + theme=NOTE, + ) + g.edge("supervisor", "note", style="invis") + + g.render(outfile=str(image_file), format="png", cleanup=True) + console.print(f"[green]Generated architecture image {image_file}") + + +if __name__ == "__main__": + generate_java_sdk_execution_architecture_diagram() diff --git a/airflow-core/docs/img/diagram_java_sdk_execution_sequence.md5sum b/airflow-core/docs/img/diagram_java_sdk_execution_sequence.md5sum new file mode 100644 index 0000000000000..590d397530f46 --- /dev/null +++ b/airflow-core/docs/img/diagram_java_sdk_execution_sequence.md5sum @@ -0,0 +1 @@ +5c927fe1f455ae4fc5bf77ebf7fc7ae7 diff --git a/airflow-core/docs/img/diagram_java_sdk_execution_sequence.png b/airflow-core/docs/img/diagram_java_sdk_execution_sequence.png new file mode 100644 index 0000000000000000000000000000000000000000..71cd2ebbc4ae0186613a8bf679be0b8f40ad828e GIT binary patch literal 189307 zcmeFY^;=X?+ct~^0s_(vii8M5cN%mGC>_$$9Yd-pDGdW7rBc$}F?5G?4?P164BZ3s zZtv%N-sAoQz8~N14<5&6?=^d0d#&@j&ht7~#9IwTA_6)B92^`XWhFUn92{H}4i2Hf zLtNmDUPNUS@E@*)s-heY_TRssE%}K!I8ShtP2XMe@}$tC?>%&plKcF4@u`xl z&vOkopXc3%#ntbggFvP$Ag9mT&kdh-XRJAQGv1Yd^LK?*DSY?3w}bsDzH<4ipezru zp%Hin*qdEa&?mFu+z_h9PR{b*$5G{Qm59V$(*HW*>yi_^{`(phT&+qCx&J!4o~TSm zg#Xt;BiAqSUn7u`hCDam`0pVs9an$e+nE9m(gnVPzVVlXswsrAvXnj7Q&f~PG3JbWDj4JK ztq}Ii<%JFf3C}gD+i+H2+{^Gt0ST6Pz0jIOLJ>5RnAK=la!bhAY9IZJf%`!-`|H7i zNp07pas_@*Sjryb896MbJNBowM@;e*CiX>z=rohEiMYk3JQBCzFGdAahHHfJh^+0I7t-iMD`Ts)Onj_4{XYV+RJrVG6=PCp;e zWeM?D$$pbClg^2`^U5fNwAotx>2=*>FW$bbI5|_NX_1^hBZ?G){WaYp%G!bnTE_Th z1e2Uxg(H>-vJV@9tk>ZaL<>o^+A7`+T!Q#rVja&WG-rNT5N*yzwcV@fnKOiRQl zt)ZEhx@)rV9(yA`o~UtgF}QE^@@u;73R-o}PcafPg2;v`=w4q87k$l;R@thMmXm&1 z(`>u9rtu>tE3wdxy1bU3PX%nUD-oD-r%J`kNRte7}0O|BA#Wq;`z_mfX?>#g^h#9qGBKdJaTJ(WDv;-A=R zB3bMR@ALZ%;Oo&9U6VOw7AReQqth)|dvzlG}LDz|1sK8RDN> z??!}lF|8XaGb(>rC2`&{%>V84Sbq`v>`VWBHKs>eh*Im~Jw~^p3934}l5*9=_SqFV zQ*tem*Z6Z42@9(EQashB zVBFK>?DnyUaMtMl``06j<_LtUqj=DufNY?O|LxBEIF>n2o}>$TTs)7R3C)HU>nQ6? z%RDu!-M>t+KZM}}{B0Yjl$2CG!!@XS%#7M=UX#ip&G!ugrm~n%(NR&K$66i(xvLMw z!SW(O6LHOFK4Cc6ymLv&pQEKJkguJfA(v?)XT=(fUhIf1N+D;kabDHpv$TPnUL@@@ z**%KUFcr@0dswP@6}k|WZ-fx1F8ZRFe`gkKBg7{VKa;}AlHTU!Q7`PmqIEtD%3-KP zKAUi5-W|8)7n1-_$;m-4Y({AFT)wx)>&!_C-=rb_{i3NuljhRqHJh!N2itudJV}S1 zmXwsd+zC1cXtO<&amzqNrZoQH>4UcUvG_+$3vm^JlEr&dEIOuUdEpOBLB?I(+k)Yb z_?Zbanw$GX?Z}9s0<;WE9=Sk`BTi10gj`zVEkUn``J7VTUV>2LO$rvnMY=beFB^|_{fi^B}Z?YQuEv6Bf_Pa zS4r7JM`gP!rDt%;YrkwWQ~We_la_mKLsDvnR2qax)!UcW<@IG|NM$RoUD&V*Y=Ef! zw#l%k%xyQv$UEhlS(&drkQV!wJ;ukwI=p)2 zxOY!!DO-;2t^{ARM+e&zj891E>X;S7Y1Zr1txpY`Rq6{V3jCTRMl80p`=aISP88N3Glj@&A`|5GIBRAyH*m+%FcU@vmN<& zTWLDH<+M`12OqLS9+N6ZKEONs>oLNNBeo#P*L2-WF_1pB@(1`V$PW&REw8&T&xeAI z8~oD-eOx>c3OK`2$s!U${Q7^VH;w6+nQ&#U~U;EmQ>1(hY*}Q;sGWr`icxN@f zk;{pwQFI<}R+C6)bo@@N{>Za2I2i4?O#I2=*O!g!Lp(?AkHubKL4$>bb*&P*SJ&sq zPo8|jyL0F3<@LH&Q|;Is#=9E+hnO^OR|W+3+V zRzsaUos%BY+!=*8B?iYZ35mVn*Pd+oa5sqQ13Gwk;74_ff<+dnY|K2OxuSS?R~b7u zI-4sh`-igHjr3{LH2V=JIPpQ4g20DqJ2iHX6xV0-n{1j2)Y+Y6q~KrhGRQZZ&GCD# zrz7`5dBhxCL8gSp~v$G9a;em0#lb%S*MR@bv%AZUU zpg!HG;d9)yC|zv1*M|4r{_R9lcsprC-vWDbqTu?1chCF)!6yJR4xX@dto-SEE^2%G zvI}%v|CE#7v!g-oeaJI1@bO%IePZeP$8rHZ%X{oWA)K$5$Y3Qw7rn6#KEu~A%c-Gv zoLlCuax5&oz0}bjiO({~aXfrXEwK$Rf!m*@c65r%+9zFps}XJpBkJaP|H3ka46N+9 zwA3eT^;ka*^#G|D(?!AL?D}q0<6nx~%xEEPtB)?aqlAPcz^8<%;=*aNT@l6A-0zt_ z>D+Jf%V)Q`}F-qAum9 zC(0qu5>f^zt8i0|R_(sR`D%8GgvV##DzftGB;o%%Q782CU>8xXUy;1YQqh^3$)|&yg@j?Nfy^- znUH6GBLA`ymlO8eu`y9I;Y(osm)DHxRIykzE68UvGw8dW{QMl>H%R5T!$(>-BpQ)1 zIN32UJp#NTT%&XoWsvvAyF`$W3_M3{_K@Yt-^IoTn^mFNd3{=j`0#Lkl?{~$;G^Q8 zVipC&=)h2CmpKdQ*&nUWHYK_5NUf?o>#H-wFs{PUF2KU#_2!viRgi50Q!vqYIn&Vi zXR!Q_tK$?P;~$qE)UDO0=%uRVN;pa5x!=wv4JE8Opt}-L?%CfY+2f`H5BSBAomj<5 zkj&3VrmTeJUq9eJCeE~muf~2;jV4w!eXLma8p8AOjV1%%zb7HiB&k$O8Oes+Cv)oR zTcvohtNyD4K9nKZyXBf-HC34W@60O812K=Q>akV*X8Q$qbaTVMlZRyT^`6_m$5xvK zii>}K*HgjaS$w#u>ssGV+CNAZu<`H%`tC&Tr@HpB=B3t)Wli6t;;)MRDwx&}5x_mk z#st2Wi7XEu-z@l=%>9QGB&OGU&k1D6zOezmS+7Z>C$vDvrrZJPTxtgUoRI-?c6PQJ zN{wFxyC1HGEgc*vIeDhC@P3W15jNcxaQ%yw;wUrW2P)VICSx&cXzl2a-ZmvN5%5_6 z?ybkA}Ujml0xtM+InUNlNs zp@vC=E%a<)%kcwiS*U6Xj{ta}Y8HqNH_QR+Qik(EwIqb1zrh- zxA^0{t+G)M!k91WfRNU9OdMjkP2c-h~2SeUmtX%>b2u?1RBh2NsS&kDbhTF6{iA%4PjFhzf=K7a$ z6II*idw5V$+dHBbi`A51a&Tkg9ak>a*%hS?>&QoqPS9E}P0J6g{S&qAO_1$5Y;k1W z&|54xFc`@FA`D9sY6Fiateu=Z4#D)@M*mD(Jtv3}(3Bfq;J z{8_iesQlyDYaX)(%)t+g%uK1tX4~h$p$x$^)Xcr_#5!ca??3`$w3^^Yo_DZ?{$`qgxU-{I^1Z3(Qn^o%nx}Gq>rj7xx!xn} z6pOywHZTw=a*ATDFShfb?)H7@-pTu2P9!})n#OM%sP`4pRvMdopFjJM{vM*O&!s(;8l2oCt4`SzY=32EWvHKl7&AX(?VHmusS0*#QE&;n08r(_6rz>m%Ky zgUWUSC8tp%nUj%ESFf!IX?(offXhN-Ms10tcB7U2&WuyX?{E0$oVRoNHz3QuPV+R< zN_!B{0{5}(bv2f}c5`0T<&j;?22QWcYf zN!^DjiNT6L+Wl)*{c$oQ0^>5f^S-CGjK=kj=-bnu`^u_oyiNj5GG{QqXqiq0tzvUk zf%ku2W&R-gvrr-`J9HtatJ*ShueIj;t2*~VkwJ{`0=8yoUutFH{!CR+FHd1L>NO%# zm~*D`UNFdj33b!kTd(STNtsrsRiVxykW@A@PBey$Buptg*zV&`a-4?@Q92~-Wk|e3 z%|;&vUp%JLWIIx6BWk6+CfqdiVpZLo-zb@i6S--%I{%gJR=a@2UPovT&&M}ds8{M1 zs7GroK?Q7s(vOw3>)f;lBl?Oqcjva;Jm`f!hKGgWoe>jn^u;HJ64CJo&hKyZlS`w0 zV4By4Jx>?v`LBMsA47&Amp3wd4p8hxz4bz^Q?9`=d(v}*q);LhfAFitH1Cu$W1HFV zUy~(zVPtGk+J&BOhBeOZYFV%~hHNIYTi`mo#p zi6z)&MbxajsX3LO2!r6VpDNSy@Wg#J*Pjjo2pvccY};9X+jULzm+9O|}nFP7K#y7k_>{lCe;_-|XS z6>WH9VLnM;hrYXE9Xo#BnU#?mz3B2V1Ig5ih`i%ANN2CfM#VLeFb8&UY=GYiQj(G? zi)fB+l`dZe-%>{Z5c&Nv{>#SkNbE#szO%*AsdPWyk{=lj&62vR{>SGFwa!Rj89OeJ z)>V2Pte7iA)4l2H)K#?|P2WuW{6;BF1Y|t`xw|s!X4dGlt*rDNt$inkS+@Ofony8Z zOi#`pUG*+V)M@@TEEe|U#Q>&jwL2<>M!@&}=}wF4@3n1Wo`M8=TQ&c)UF*GhB1o#d zT&8Xlt`~^1j}0Zl_uGsa!0wWJmfq2{Rf9vR_PWn||A85UYXh;=2L zriBOSLuL1nny~$z#}omvnR|vZ9+ytn#O0oy2(jL+z}}dN=5t#}+GCurVICVBoH6NA z-Q&?|22yU^JQ|2;(lE{R-v(Kl_2e{5HEJLAX;8KM|5j_dfr|zW(}}e5gz3%0NU#ik z*qc_YTi?0twDUs$Zbf!QMTPQ_NYH%XQm-FP*vd=$k~n9m(mRMA(~ zbKgdvWv1!x#!lignUcXVPv78@k}-7n?|T8!KFs#q9*4&DHa?UM!qD`_GFuKJ)YX0n zv3+)cn-m)~u}>dXTMz1DPG^`?)z~52Ca+NqwS*FYICv~P9jDqHI^^SQyp#!>*9DrqYcO{Y+Y)~{f>`ju#-{)2zb_b6r? zkA&&N#vfAt9qW8rF;|@J@w{xuxyrUGr=}ckZ2Xr81eJCZ3YZ&SSLr7>qt$karzUb; zVr+z9!5kOO)+=)|2G+L!j8AD5xYvBE`I1gae(XVTzqtzYgcf<@1Fm2X~e~ zMs`d3@Zg~D<5t-ZdgBBD>wGIX|BkDV*RPK-|&YO|xhE3@n^m^s!bIyRp0%^O6u4NV8hTb(INuLVWj@a~t`Y+hP z+Y0O3Kb#YmY(1H^m`HUaDBnjMq)6$?RjImAv5idrDXql3Hvj%~AuYDVqzC;#hN9o4 z_0knsMLoBxmIfMSPnQiKW;JTcwZm78F!dsR;Xsys-O>-7GyYpjUtYg<^>l4R_Qo48 zHsUT%McDzUf|KUGZ;dAKaEQrqUe8~m1_-P!$f&!X{oHbQmU;W+)<22YEYy2%q1-~g zLvz%K?bx?5@LD8KJ)?aj!=L72N)&QQ9z?78;b((CrVKAgUP+0MI3`9dl`kUDpST=+ zEV3iB+j_8^fvt3$8~06t1$FuS;t zuJ+XI_R^LT4u62-G43{B&8ZxNRpv2k{Rq`SL94&&05Wlt&%VuR;TE1_Bo!yY%QMHh z0{sfI4;ty0S3itemYcm2m;Al&#fiGeG`A40tP4irld~H@v;gX)dj3l%e<-{PUW1zE z3tGH?yHpVcz`hZJ*Q9Y);D&pFhjTEP)#>}j;wD$hx=Cg@jpg-i2n1ptdz?t5Jxem>Be>6ZM^L7mfOw1 z^(`w;>4X*&{uCM3QY;`b->}m)#kWoZ`?>`*E<+JhAYH5s)WA~F$W-)?Y4`Z%=I;p0 ztcy9BHwuP3_m+%AktbKdvsJz#^2cdL&CCD>Xu`UelMeyLlX}_dlfF=$Ai~d-lU^g` z+`%v7p;x@t-nmLYFDj4~A}JKpT85}%J)HIes!zv-rmSv<-^``FCVyTgO!I2~>(`Jf z*RHWe3W5iCDGo-M9fGW^SQUwxt=m$TR|Gw%aFCH z*WU;Q9J_w{&S=5RrC;b4eu|1B^g_wUW_Vtj&o~4HQHy!ZFP>@wS(f<}t+=h7{KqiY zyVz=VBm*%}0K>{-ZibUB6czEVg2w6f^z_`3Bl5lHk}|QwWMvyYm-Rperd0-S66-#$ zP7YUc?{*XxJvZMJu_;#h6b*GsbM}w1rb`tqUyP+ZBnFsroPvEk) zb2s~ObOex;)5RQG;~iZb1}8~F`qKm@fonOn{-)uF;Nl=Ea5ZIiLnVZ`zkA={#x-bz zj%J*PzD%LG2bY>{nd45JFqLQD!li=QRFW)ql4vOocxe9fEy75ce`8a!j%S8svj!XK zM4eUo6LMX>-Sgg~M0~+eW|@aXMAq2b!1Qy;QRoB$PqyRro9(40JT?`|Y?b85iiA)s zdXaShh+f!H?Z%I2t-9HMjO9vUKf9C^duiK{dtF5Z%9Ii@*#R1&83UNV?a+yQgGNVu zytDn@sj1}HtAHWVd&6Gw9>@{HgwLGXIX}%{qpm(a91f7;lbyMR%H~u0wf!J|KqDZd zp;$WVoa^3YfgL zdFp9woVC5ejK;j#zeDhHt*{l^nTcs!XnysPA z=FW%9PE+w_59CJJoh*Za^wyQp2tnkXV=tGq*z3fK0I#Fzk8NMxJAgJt_TYr6y*kIz z#|M7%BQe|0!s2*SF8vyN83}AQY6Dtjx6Z(Khp0LXGR` zm2cB#*jA21qrm>(t#)!BAXnw zZD%mnh1ui5AtE{r0=Ap*Tf1`ou+X*P=Nm#1K2>9L(U~9O3|E?`k$BpU;E>v1Y_)`@ zNO;-rS}uMcN##e+s%erP@`dIoIj6@C-ptMhv#^Z*?ry3nuM@!%;7N;8ZA^{?Yw&Z=k9!~c_0HLLjq-3axx_>nkmu;ZJgfZ8&MXV{t~5mrBi4i zNigjI$*G{|=zK;7N)!*=ohC3C&#zKj{_bY#JAVXk@aTb3yP|5XB{IUoa*MP+ar2Le z&bUBFWABK%7$9;rQ4viJ7*VHt&hYO(x9eY+GBpz#qqBNjACs;paq5K5RUs>#_iz=W zG0Y&N8urc3`%<4`M~CE6;*%T}9?=SP`DO6;pp5FA-Hv-sS-a`U80wM*KIvsj9gdWl zmPjz!4T(~x@R+H*dq<(l%)XbNkzQa13u?~t?*b%`tROnoOet`~O7Q2gY{l_xB;G&j zS2$Hv?z(yx$GJzW$1m?Nptv+{Od#X$x!UvFul0>`GZAF-?1yfyo~*W|i$F;lZBT_+ z+1arHml@BwZ;b3cXp`lFkdu))Dkse^Ea;S>6YJi;cHfLFjqNHv0W(x#K1{LGVY~Dzz=(0FL zeYh>PmWc=~z~3us$=9}4Uc%GGw-ySd9kA&~?-t%CC|UyUp*}(76dsTlYU-DBi$wKD zcoGCOf5-J^RsNGA9f|7Pr%$N+4<4`;SXkSV2S!G^s6|f}WH)W9tV2U^BPioRc@d-)l-u<yh! zlLb175?&|5!E~CMBE<_y#SW$m_e;S}PEL07Ih;EA8moRUabVa(dJfG@oS&vQR}nG8 zcyF71m8pizx=I%64kUv!32$%JviPGtY)AR5ww|b@@I;3xdSYf=jDw3yA1`$bc0N;2 z-<0LN{c05+-_;m&(;qRCz@~b*@Le{cdeM7tUeK!V33?eey3r4F-}?Isr|^B*=O+J? zkmP<^uRf7@zq#W@?}(e>`}gIr(zZiCj}!V(ef#5-3Ti*79N=;IiGK`BDWKy}<|Kbi zyrz^)S94}7nQKVXt5^8!5VArej1SLkWB0`OajGgA*VBrtwr(1v=IG?1k0^6h zO#C9hP2zYrTiZ}BR=<%Vy5RNUZ|;mEkIHGz(O5%Z#wk@2F^eR$hlEzyO;BNYF373q z1~Mdl4C;6q9TIoNE1}rY%;-guUL^t&5@gh9>)OzE)z{Eax06}7t8?aycl=hP@fpB% z_gvSnh!h^t_tzB7JR~IQ9^JoNq+9w+Z!~tUsaML^AHod7{H&LH7S!<<#G875sOCNF1NU3;2sH0rciGb6B6x|(P- zC9VCujM_Sq;_gq4+@lJ^EH=fA5$QvNE0VhX#avezUi){?-$24Z3%`H=7V{qq2;ubz z-J1SLs8`0pCzdK57As(V_C;=n-{F$LdskMj@8%jK63pLfJ0zOJ$jI>Ah!stv*bidP zTAZs&ko4DW3%K{8ZxrUv!I$bm&CBmM?*ei6DoX988pr@eZEnxuk&=2|`|)v%W+DOk z_#YJvTqzeDZ1G-a&x*eKtO~S(L%+EpI^?NJ4Kx9$t_sAy8k7K_HPI00w}CoCi4Y5~ z?#)%(Jqx17FqtMUo9{Jl@pQOvggf6!=eJ}ZJc*X zAvs4cTaN4+q)VJKe{kM@-|UO;zR_oAFcXcMp%roD(g4!~r z{S$7a(?nhDX!W*`hhvuaYt(WGhZN^)96CF`5!U-(i@+~i3f>iv(N3g|g0!@>j1SIZ zDX}-#=a+}Q{P%Hj-P^HuVQT3g40M6ENFrl!fC>edGgQV8v*lsH{<8CmNI7{tAo1c{ zHmK7|;LhJ_y@wpQ(!49 zMgTnB>}#p9O%>QIwI2-q8F%3?AIWKhIY);H_%6x;1k~+pjZHy4hKar!Et#B9y_E1` zxBVXQYb2kVinwNcTw*5XEA(#za2%JavIv!Tb#+Xcq)T~OSo*z@BaG7}OMPqdha`_( zoSf8SnU=WA!lG%0{zuyCf??5sTD(=kVN6Dwy{a5abPWMoh6tc}G?WEPy*yqgehvR+ zX*76}pLLqM%yGjHRlB$ts%2f;nJJ4}et;FrDlw`P3BHv{?CVn!aavUCKAI}8to*ID zj*B03jwHn}j9Ig8Keh|X)W?sk`KOCJm1D}Q<_Spj$8#YH*?FK_QG%8)X4tGh2@2%a|+3|VS=)rTtbBHt0yVi*i!W zeRy)5|I?u~*;w%LI{adHwn9e$mfP^){Wk*2xkj%j>*B_aZ&3gdZDkF-1%xdI88V+m zEgVr%Q379ToDZ{qSN{#+f4;wZe6AL78?*H7<1Gg6iJAwscC)4_{t%*gp`ZFpk)+CJ zp9z2)Zv1{*^Zk{V$5fzH39m@NhQt3xVIYZzyvVrJxB+MsvWba>lXC=c8kGv+1o{Qb zouirA{abh~7cwOML+MG3p30Qv6ogC3#`Z|~FMfSxHilTOpEHYL8vI3$2$PoCzhcVr z@z3%d_;`s z3(oH|%_YV$JKY+{H-Sg#+{S0Y7++SIW<7St%IMgP+79eqR=|1FMM<)nmt{U{%4TfR zi+G%`rv9N+p@=?j29zp%;)~2SD**s%Rjz!3Wi@E|WBw=Mc=^GenKXL+_KjG}(aa(S zc&4M1)1ewl`IK&-&*@68P}jd#TPFCndB}_}3J^u&q!qn)85satll{8;NbO=XKR+h> z?dq2yGqEULC&M_91J&-{o}0Tniyk^%=%aqDa$;~EfMbxO_eCw8^;yABfU#y5781oh z*l~$&fApx+^)vGJSwMwoe4vC5H-7r@nUZxUA3EU@0<~( z4ghcM-_H)y=F@c67L!;>mZpbA)2XzuN<|p{@+A%E2?zs<_}PW|1TF`go|ApWEMlij z)yuC*-0wcA3+9jg(h97!Qi^RU0Rf%e=PSu+Wiq=1kp<)ydubh)&9iIAlFZf48t7dE zxAHoUr@dv_RVm;M}2Lv+{?|)dZTa57rfcd)1$AViL!QV z+T{A(YZzF)C%19R@6&C6YwV~|pUkV96qF%(m#_RBFX{OTQCnH?sP$zi2q%eqV|aQl z_;0SYj(c}KdN_qkvII3_e*2ZVK%eERu}-N~$}#N*kGa+z9m;FT7@70&KZUhPHQ5`x{|7c-qC& zaWSr#cEZxdR*^svkHBveP)L^a#VswGy=rV%$=6@?4B#(X78*C^So=*^28TyR`jztc zRaFQ``%NmyZBS0yU)@Kb7ap`-j=^XcqQjM{;lY}=mrp&;bMqo1e3;o>LI*n=Nvpy5 zI1RTlj;=x#@(K#>D;M|@0cXOZ=*2rLX_-qGTP@zWif8sHSsMmH1nBMuHEdAxjL_#RoyijUwi<`Y z0+X=+wN5yYEd9i0wc@B5spzABVteY>yik(@0JET~jfAj+mFqiTHwGEAzrVi>`WhbxHC?hk zv^_D}td`i{6`vg@9?*q(skJ%q-t=0Iv2Bel3cuc)2$<|LxAj^79N6K_kLjxZvjt>> z=)*TkKu_w2@IM3O_-r4byWL~^n3ObLPc=hgUps%KY1G;vtG;`C+4aiTpN6Jz~SjLXG(5hG^pbZ}rS{3d86?a*(Lg0nX9p(Agx4_6qd~pm4{X zNM-{>^ldS3wybKv64_qTvOFAXBNiM>r#Ah48*n^rdjqMfH^tz?WG$yh(;m9bu&J+vA~f^zBvZGM5tc7{d!qET0Xv7wdeV6AK+ za|&n->({um3EJK|=|2J-Y|{QgZPE0i+}8-O(#UOzb^`&RnAQa#GG6RU9ba`aOMTcr zvUXf*{6;@hw!XXeJtc+ubZ43)Sb*&FdQU9dbH2FG_h5mDRJo1KR5AZMf+;1Df-wD7 zDa_Gu6pVw^T41brKgGBS9A&Uf!e-PnYjP#Sloi$_dFgJQuUM1^s8eIwHtj#6i?jGw z3)cSy>6E7h2vXqejN@Vp;QGVm`{|%a_pT(VICc8ADJ`Oks{A8nivyxMP#f7WlH=HP z5g_I%6sFm;Y<=A20{i*VlowNKY>gi9+LdwtZsZfvmp+ncQ&Tv;lA)q+V8C{jvF!bC z>k*CPQO>q;gI{#}n^wPPGw0#iA@_oZ(yz25}uEiuI_-x@>keL*qnUxp|+ei!w zd~Q@a8TC*{4*ua;{%wDzWoy7v3%UHUAL-S~o!*gj>zyxbm=k%kUB~5S)eDLkV#n)a zy+IUUB^W;&Y=4AW$|%f@qNjm|H%9{n5Y2HXRt$W^$|%vSc(~T>=IXk#(T}}AmC4rA zxh4lO2itq5@=oR`!(2Ca-iH#?+IT)M`}Qo1#N!yDzNnLDwcbNi?Xt{okQE%iaBTp& z8SKJmka16ix5#!#!bHu@&Hs^|Dx1-@h0n_t4z}lu>y+_&iUfFJwkn>|{e1<&u7~z7 zzeK=V6a(<3#P7OBWoEGw;3QG2k9^SDw?2_5a~?j;gy^AG+SX&Jqto za6jfzqg3V2Wcv*&9}WN%LM z#^;50snRQ;B8VFpPLAx35kmoU8sxwY!*By`5xXrJVk`(k4HfL zqRXcfmi^)Oi$d7Pv6kyF-kkRttfQgPsQ#?wG(%yB3h zACe5ri%D+1AM=!hl^<^qR^2DH+D>M2_b+^UogpBj&)9EK^^Fa00UG*4)a83g2BT~& zHhH1WWqmU_Z8)vg{d~kkA(r;lbTRbu-yJ*#BM-2uK$`sNcscI5J4*$?GY(?ZsH?dl z3a7lPjw7(KdJbc^Ho+Y$ko^P*m0{&u-@&O=Yc!YYIB-# zv5{UW&95f4%APT*H<~@SD9YL(woy3!U@D(t7Am+Xrw3}%PhoN@ zdertneRZwAV{YpZ)o*2i-XY>PhCv-fT@uEpl6S0Cj$TD6%*q;P^;@mgbz{q|I(6@c zg!Cta1W#Wi$NETXMjCB+Rk}~9L{f4o#4xxu@zzz<9{ z`~###(2qK+9+wI7ML7Sti}rfVCG_i=^%?gZji@QLQl_AmU zQm}vHv%@NAJF>CZ-~o^~t)DON06xd?eA{`}7T%-0)K*v832N>KLo=Ju3GjHcw=kVv zkp-v6$%z5`uwh$p&@BRGy5D?azm)zclL_N;r3P`D$9;L}jkp%v7cT9^1JT`kOyM32 zx+Xr@_IG-Y$Vj0jOsP0N&>ElyVY=!C@&&QerSSU8McDCREwayTch8YCVFneknd$ux z5Nnrq_9P>|f6oa@CV+q$>8GQ0M#kWoeSLja-N&`sfb#K$vEs1tIQ-z-u}s)12*M-|YsX~gXx7HFq&3I6j%0QtMxZsAKAZczJDlMk(^H_Jrc zt+_I;u*=w21n39OV>+syv1h#(;KedANlBg30J}!7;H*D=e|(!v)EqAX&F(wX<+J1$ zdMj0<*A~_f6k{(c&2MY~`yAE2PvH0!Q{X5!R8Y`&OK)~|RsgW)VfbtqWHm2H>5bA@ z1CjZw8%bY+vVywW%EAstkKRufnS}v`q5IXOfkF(^mzI{mgz1c=z)RNAN?EeR+2z;n z=g4F(Jz8;#M{||7xuc5AJ!yPHiM@mXr3Y-MDpU6F9}*L<0v1lOrbgG3a9+T$wSgF3 zFmCmPzWz3>8D3^s1DUHrOk~^YMW#F-m=i?O$~vm=T7x+O;qMT!aEJuY`%{r2y>Y1;yV_Sg|)%$3(PodjFo?BVy z)230ap#8jAz~A4bvO(~ah0mWKU0F+524U06ELo$@t}|IJ4K?$KXkY$##|bnfZ2Ln& z#qicdT`&7CLsI~CKq%k&O_)+f+GPH5XQ>W4>|4eD1%9Z;6_FkXM`1GOE1;oehB+Xt z;YwO6`czIkazv@*djyPhm8xO}#S=}1&jj@SpgoOjdL9!!{d zY|RyN3d?RQ1G_}I>TU*a*Eu+K>Tw9Covj9%yB}@AS60Le{(iO_j;@E&^iGVE>@X=JIcm ztqTy__BlaYT1m7bdWrCtZLFy>&pp?ZEiJ&&IEqyW)GVAVxT9a|$Twju%q=YkC+!k1gNCrFVyk+Nj!qxvep7;ss_#A#;!es| zjNx-$)HhByd2ZG!wbT@>WMyU5Y{DTkcn&royAHA*OtJ?2A^kLKk^av2_<19ZgUB~?I|~Zlgek#qM@Oo%tOAsJRO;g zWxevb%n5v3{gHp#MSm11VE@)SNw(5nfPD7htN9hO{MI{Ge&;)HfXVPoF-S3a;Qhtg z@vXi=d;Bv+gSsGTC&N1BlfZaU<&-?4t?}wR2M3LqRNjzaXiQq7;M4$w^~HET+V3Tr%ggS*tB`S8y;|KjC_FqX^A zbWaLFuMkcamzw8^5#k!Sr`XG~M~dCB)uVTDiT@D`=~A!piWM09LDKx-rjj?a|!tDI|&^Vf<*gX&YA;BoNiuTzmyXd7MsCoMS5k@Q$8`AfCEDp zB++x}b2)B}Q)xZ=8p*?Wa0u<}c%Z6Z{ehGDY)d8d(RN}rItM+Ne^|VH<92uhVzmvv zVv=odD+_&F`3_&^vRd;C=o*-v2WKOrfWI9_%zcAqdptk;^<^c4OhA{^{sAeli{oG* zBMMvPvplAi6iDA&wsZo2L-_Qu#8)~$m%G~cxt|kwAGCjIL7^=DAIopEun;_dLQ2W4 z6#pXpljmzfkmtjvEw<-3&&uhvpML(laV;L^(V@{~W8-7v9qh6EOqq}+=-sI4(p0U9 zgSez*QBX)@qitV?VU2tANN?9XBJk37&Hjke$KacjsPffeSk`2|3-7lur03{Fu>tuh zEV}it+p#^Zt|lonEdM-{?w3N1gZ+7Xr{CI5h2Qpi|G>ZiWXgUDNg6fD*<28Mh63^W zh<N;zSnyW^!f3kw36HE?^twE|$y(c{NEO%$OK9*y+78zghu=YI=v(F&EP6B za&k74s}XTMX}`taL4nu(g9}EjtwD`}fA{RibGFdKJPkhJ;>BKVCeVjY8u0@Y0 zBqr9NV`V)DLDEj^!T`YP7&lp6+Y|`*Zw`vGD)H-g`zxwQOs{Z9o(SOu#1V zmK;@bHUTy{BUwRGle1(Lg)In3mYh>J4U%&b$5SpBGZc-DzYW8;DbJMx+IN$s0 z9k<3%SoB(}s%Fia^O?`As{XOiv(75A+^!8R7pz*4xJMrylArJ4;mNmE&=MJ$kO2Uw zUXS2GO<91O0eJ~&t_ETbm5`=^)VBChkC~mEOi50LQ;B=LAIR0u07-NfMn=hxA3wqd zaw|qgM`!2e@&=GP7$Q4KVnS=FoW(_$`+8c%$#IFNW}Y2brqy2yG!{0lEjw_^2$hk6 zs(Tm~4)c?QXH~_Cj*RqI81oREy?nR@Q`%_$RRaDLGJnThy>L4+NPd7id5hyr- z#bEMZj*-=3Bj`NyX~XZ>&u_1-| zM<}#OfBv1Ih>-c*U=oF(Zdj$`YPw$4?gGa3YCpn-w8XmaiOOcFf4bpn2ST~mDOzpD zy+^>QU0~MgDx&$?FsxVPM9iXsi0O?Vf%Bf6+4VyaydF z@QkM7&1M#H7tt%fp7s4Z1G*wN?t`$CMF*^8X@7HiYrFeE#RHfdnQdThB(2J1w{!He zOx6x=_o!dAO>=N|REujSMrYb7AO=3=IvD2?f`AqvpYyy!MJbBv)6RbHYf{WF>`;C|M?maVqi_#YF8Z_vPiWSe78cGf%*&d}H5kkrEp{AIk)#z(j!h{kwwsp9NKem} z&EnGJO)_dpC3D*FSu39=!B_xBqVH3F?T=xJfrWuF&)W`bxgdPIJ#c@|ZVPss*~JdK z;1x*jahgzSd7v{n$yp4;%EWY;IIVoC5^-ggX6CaPmg|b;4`)E?RY^@Vf9mTsmhG={ z8CydUsg%IJ>c*cgj$wv|v>F=Fw`o0h0-9MDid25$dJWsS9O=J*k5|JEHJ9i2(yW!C#iah}{R z3;R$>I^kTxOD7H(V zsVCF;ZO8u0=E31&b`^TD({#8-uF^u~9l|DI$ZRayZIe_rC9SPNe2;dITo&~{Ng*ar zF-xwS$7|&vzN$xphDU)ean0=jJx})Ht`bT?A(qb}570$g3tIsqr=cMda2=b~99j{V zrEV-WpVhD?mEhNWB&MF-_#s*K-GI0phTh&@$AyVkV7wB9U9~k3>P+D|FFNuxki$)p z<=;LGk6k2S4|3v0xu*!?`XuTV&z(CtoDRV*ja1ZBSJx2fS%V}T-*$)1ZO@%@P;mw! zU8N#1^?;_FTrMhJ2$4|rJKu)k9$B>R3IEr*q9CqQIbEyT)F|~CXZGfk3+?Z8hD-au z3*871b$nsPMJ_p(BrdzIm=tL`^2M7roQ8Hwe{B$PBbDlLB(`G-3)d8TR+p=lH4sGg zQ`=lmcusHiXuGdW)g8OqU;AR3d?wN^sU6cOj~65x!G1?m8}!z^M^=!?J*(;J-G$@I zp@`qXW z)zy=3K7~nUsc=@8X5E%ac0p-!W&vBCjg1YZmoEuufkax%nnu8Gu?Lvp8X_(5TievmQkwb$axI^*@5C*cDrHn}#Mg zl3gR)#EcX9qCi(Zj<*Y~jS$?f=x?{$Aw2B}H=1oHt1NLzGNca)OcSgK>}Y9`MrxP# z+Q@q0;R2^#r5dfUQ>QvdhoV0TEf(F$?KDw;az4=MpoUpS_VGIM+gP;Zs;}&PVVf$G z`Bp@=L}5!}x2s>@tq_SE@v>cXZ~~^p>sVMmfnVMC5=o&`w06I3a_*ahY1XYR$G)Ew z(mD1oQBDW%ZGJDdYmq>%>C+w@Y|>_GZ9;Lf97h9v_y1}m9BoH9_Tx?A*vk`XJ^;x z7_?EFkLsG43p*7u`X+D#Z0>ZckytADJurCxY+7hPO0E+X6)Se1rz^3f)Ze)nEiW)L zR@IL+ApLP%R=nd#EAH`(yLRW(J72`G{&?N`qr6Q(uD-7MiEWMSJPrH2)IgL&mwR%I zGg}NB$IgBlqPqVzX=Qa=;`@AA^;CJ7oTkN6KX*hz0@7v7^XYoEn6`vR-E&{-I^>Iv z(icd{HCY!I0Y*l~S+~{wCTl@%*2R-nMZ58tiqXERD*n~sG?zrGSJWC* z+uT+FD-C-yXw!@GsPMgm|I{CMnovr#vrxaP0^A^#{W9m_XxH}s)x$jno00XpOeYs0~z-!gV^z?R?j6z ztqxo0*4}PdbXA=5nnXHD*9m1U&(c@W$0zUDI>uXc^w+G1X`P-UPq2*c^KX4!qa5zl z82E%OjUviwPfSXKX@pWgfBvl7_>kvevHh&fXm3%LsHkYM%esDv*=D!iEN*wb?hU7^ zy12NwWU%UWdIJMIT0X7yg!S$xbuWy0y2}<@n=hIRJMJgzdo8LH5Na0H)Os7NO{Le= z)F9oT>15u_?Z&E%V8hb^8OSzObs4)nXdvQjIq1+`_eOHN4TTzrEwOMM)briGZM3^K znl?E8MjkA#*C_dX-PO$vHi$|9u1USNVjD9?z{;@~iK=qR*TK%6)})Mk8|2+$4qhHI zP3+?H7%Ovd+M5^|6*PJMnnj3F5=Bv^>)_&2Rx(^Yv;XmyYn0Qw-CPeq&Qpi?&K)xBcA{*8n$>UCG6QGKn82X)TtV`|a?OxUxkF z7m6{f^iT|Jw3IeLR!*f3buj*8zv-Q1>?y{+=6FAC>2vqq_1m9J2hOn!b~1&p)#6Av zb!wjkQgEvqK1nq+GQu|QNtnCeMUI0{{YKoQ=e)!Wysdub&dj_9m;|Ocqwl#h`KGD4 zxqYY&dUE2WUE_wldFy86^`zu{)9${v7b>`4o@c+fFq6|)dEI?7V2+;FQ|?xaH~biv z77>-0w_pBdb+ichF0T(>H(G66n?^MxjoChqd+B7#Q!X{S(~{A>*Pa_Z~6 z`iWzpFtCW5nm0Ee@Hyu5sOK4`ugyR+x{}J0TUS+k@eXlPW;d&-C|uubR)|0ynJO^; zMw&Tr0VkTPd;zIjAXNhU&^|k*r}*lXQ2xRGAK~mRTQ%?eOBq~Bco1Arp2YI^^6Z{WEXXH4?*e0Pv67TaU7A+$urzR!k=e#nKqiNu+UbQ<@OjEv%s@lQW z1Oi9rmXVpk_Rzj;uJb?&WYtQX2({wvh#uP3-#OmdR>TLvVD@46qUuV_dO{er1x%Su z3HAxi&`V@8&z~!KcvPEoCFUGQmoQw*ReGGIkkJ33BhNv$P<-&I-JoKau=7f8@eZ%& z(PoSN-iFefq7C-gZV;T_rl-i_M`C7TToQzxW|x<<04=S1l=$$OY&3UIexSGVlPB*C zjk+xw_O2GM8nV~CD|PWA>PSfXHm3*D7rX=OrE z7M^=gnH3U$>|dFFU83DzB8zg^qT;{SR|IxgJ?p+$~+8J#Atg4ds+H-4T1%TjybR?2VrGkIo)0n9`e@P=j&LG6Nqc`{m0 zwdoe`R8QO9 zHhx~I;d8BYrrV#dbis{XfX}S6`v)fZx09W}?YZ>H@WctZKfjw05TvLS9o4nXA&~2w zecr>v64}-cZ++_TBrdLR701iwD0;69&5amD$RyXr4s>8fPmac;mOJRZ2A1#9sSOS^ z+1!3h6Auub~l9}diXR5f0o zNQT{2M`tN29_X+5%~ViGnK$of9d>&shNk)YQt`o(!7e1$B?FgqupeL2{odYUg{gtE zy?wx?u`vUeaZl;c?Y-Si<)V$uSTT#d)R1Q#4Q3E01TSs7O-I~Xhs zq{E~b58v5w{GgVfdG5lY`Qc8!OtnOSO27NvY}gO7RYh>_gcxolJXYAaa?E2|ujb@1 zkSIr=bKH&^v>lBYU|TV~Dh!2J6A`DYn zvQ{y_`}<%)wnsD`bZk>D_2rkTsH*ZgEm6oo6_jmlKd;r!D|kNeoFg;7WPLlS<{-89 z)HCP=w-_z#F0L^~XAQq~GgZ;R`!NW&pNAbfTQURIuj{}mX6$%HR!HzC!UTP16pcWf z_o%*_P?sL>?=N7Uj*csX+2*ggCRu2HJ21GqE{6Rgp2(-@o9HE}5KIE=NlrX;gk`m6 zH-@EuW{X5;ntx&H(o6R3X_=i_NR}#jitTYwoD-2Cba$`8&0IdVIphfP*n8@^HN}Ev zYe>if1`=J+XNq0x=Z=gDP0?;pcQP2)KQx<*H=G9`-%BT&#NNSaAl@L#e5-nWc78TT zXShVAuTrQRTU{efVXTvTxKj1O4Oc|DwY#BIrZGunH*ZzydTnh~uPrBs>bS|0kufV< z%9f0{&d8@3bu{n)lX~;`e8l@9%Yi`d>fCYfQ$1NPoP3h^NzCgmR=4*8q-9RWgH?mb zc2ubuYQ%=uYl?YmXGaC>UUga*duqAOh7u%L#N^khsHpBN_P5C>C}`N)@)1|e>wAiO zYJVJ9|3IPNzq8oKHzm1O;-CixoJEv|=x3JH7d=eECRZEk~9&?}fL|^L?igsKbmIns3 z_xs1`h{VLaB{aff!sl@;|8bG-Hta|8TR?PSo8$7AJuGvdAO?R>=hv|c2$Zo?F-tVL z;#tBbAF5sd2+Vk-3Be9{*ZJ(LxKgJC^i6AzuO^IHQ6s+Jx{qVu%w$;a{- zC`)te?+xVSgy_>4$4Q@}Xp617y+(1pr>qZzB=<-c(i&S7S<+<~(U;-zrZG(6&;)IM zG@uN2zJH!N_A`F(Epc^ie2xBx^u0A2rI{TIek;}OL#Np_?KjciCGz!6TT20U>h+@) z!A|(p9*E1B4x}~}zCgO+`nZAnHJG8=#ZiRMOs%NFUE8U`vD(fI#1KZ_{Xl5!>7GGY z`aSxYRbNWTr>*bt+&kO5oM8;h5kr@<8nE_T3DS|}ZYd0`jwlk}KFBO4<_b59dE-r} zO2Vuuv4ZdNY>#=UcKJa(gL%hK>Saov`_1pKOc&dnddVm9HZ8CSWc=~Rfp+!2raW%N zVTfYWBi+LM=)Ikc-18sH`E}+m{Nj2FS0A{U570q-cWz+Ob*?@QA8o~swDJ=f+RAh^ z(TD3g&3=z_D(624IBDivQ~g{W7WK-^72fc+Vcp$}pKmQ4Dr%YF)#Q8^m5|}X*%goBi3lMF2@lM|WdUZ3p?&>F1tj&vU88M7yDl

Wyr`{ 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ΠLem2e*wkGPV`KmcE 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 ajIQy#N5@YcKMGGD$nQwt;hLYKulx%dQuuTL literal 0 HcmV?d00001 diff --git a/airflow-core/docs/img/diagram_java_sdk_execution_sequence.py b/airflow-core/docs/img/diagram_java_sdk_execution_sequence.py new file mode 100644 index 0000000000000..3c9d4ae96587c --- /dev/null +++ b/airflow-core/docs/img/diagram_java_sdk_execution_sequence.py @@ -0,0 +1,327 @@ +# 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. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "rich>=13.6.0", +# "graphviz>=0.20.1", +# ] +# /// +""" +UML-style sequence diagram for a Java (JVM) task run. + +Each participant gets its own vertical lifeline; messages are horizontal arrows +between lifelines, read top to bottom. The **Supervisor** (Python) sits in the +middle so the JVM <-> Supervisor round-trip (the JVM asks for a +Connection/Variable/XCom over loopback TCP and gets the answer back) and the +Supervisor <-> Execution API round-trip are both drawn as adjacent request/ +response pairs. The JVM task never talks to the Execution API — the Supervisor +proxies every call, so the JVM never holds the task JWT. + +Arrows are colored by sender: + +* teal — Scheduler +* purple — Coordinator layer (CoordinatorManager / JavaCoordinator) +* blue — Supervisor (_JavaActivitySubprocess) → JVM / Execution API +* orange — JVM SDK runtime / user code → Supervisor (over loopback TCP) +* red — Execution API → Supervisor (responses) + +Graphviz has no native sequence-diagram shape, so lifelines are drawn as dashed +vertical edges through invisible way-points, one row per message, with each +message as a ``constraint=false`` horizontal edge on that row. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import graphviz +from rich.console import Console + +MY_DIR = Path(__file__).parent +MY_FILENAME = Path(__file__).with_suffix("").name + +console = Console(width=400, color_system="standard") + +# (fill, border) per participant — consistent with the other Task SDK diagrams. +SCHED = ("#e0f2f1", "#00695c") # scheduler (teal) +COORD = ("#ede7f6", "#5e35b1") # coordinator layer (purple) +SUP = ("#e3f2fd", "#1565c0") # supervisor (blue) +JVM = ("#fbe9e7", "#d84315") # JVM runtime + user code (orange) +API = ("#fdecea", "#c62828") # execution API (red) + +SCHED_C, COORD_C, SUP_C, JVM_C, API_C = SCHED[1], COORD[1], SUP[1], JVM[1], API[1] + +LIFELINE = "#b0bec5" + +# Participants, left to right. Supervisor is central so both round-trips are +# drawn between neighboring lifelines. +PARTICIPANTS = [ + ("sched", "Scheduler", "creates the ExecuteTask workload", SCHED), + ("jvm", "JVM subprocess", "Server · Task · user code", JVM), + ("sup", "Supervisor (Python)", "Coordinator · _JavaActivitySubprocess", SUP), + ("api", "Execution API", "FastAPI · TEI / AIP-72", API), +] + +# Each step is one row. A "msg" is an arrow between two lifelines; a "self" is an +# activation box on a single lifeline (local processing, no message). +STEPS: list[dict[str, Any]] = [ + { + "kind": "msg", + "from": "sched", + "to": "sup", + "color": SCHED_C, + "label": 'ExecuteTask workload\n(carries queue="java")', + }, + { + "kind": "self", + "actor": "sup", + "theme": COORD, + "text": 'CoordinatorManager.for_queue("java") → JavaCoordinator\nopen two loopback-TCP servers', + }, + { + "kind": "msg", + "from": "sup", + "to": "jvm", + "color": COORD_C, + "label": "subprocess.Popen(java -jar bundle)\n(spawn the JVM)", + }, + { + "kind": "msg", + "from": "jvm", + "to": "sup", + "color": JVM_C, + "label": "TCP connect back\n(comm + logs channels)", + }, + {"kind": "msg", "from": "sup", "to": "api", "color": SUP_C, "label": "PATCH .../run\n(TI started)"}, + { + "kind": "msg", + "from": "api", + "to": "sup", + "color": API_C, + "style": "dashed", + "label": "TIRunContext\n(+ heartbeat every ~N s)", + }, + { + "kind": "msg", + "from": "sup", + "to": "jvm", + "color": SUP_C, + "label": "StartupDetails (msgpack over TCP)\n→ build Context", + }, + {"kind": "self", "actor": "jvm", "theme": JVM, "text": "Task.execute(Context, Client) [USER CODE]"}, + { + "kind": "msg", + "from": "jvm", + "to": "sup", + "color": JVM_C, + "label": "getConnection / getVariable / getXCom / setXCom\n(_RequestFrame, msgpack over TCP)", + }, + { + "kind": "msg", + "from": "sup", + "to": "api", + "color": SUP_C, + "label": "GET connection / variable / xcom\n(HTTPS + task JWT)", + }, + {"kind": "msg", "from": "api", "to": "sup", "color": API_C, "style": "dashed", "label": "result"}, + { + "kind": "msg", + "from": "sup", + "to": "jvm", + "color": SUP_C, + "label": "*Result (msgpack over TCP)\nabove 4 messages repeat per lookup", + }, + { + "kind": "msg", + "from": "jvm", + "to": "sup", + "color": JVM_C, + "label": "final state (success / failed / up-for-retry)\nmsgpack over TCP", + }, + {"kind": "msg", "from": "sup", "to": "api", "color": SUP_C, "label": "PATCH .../state\n(+ upload logs)"}, + { + "kind": "msg", + "from": "sup", + "to": "sched", + "color": SCHED_C, + "style": "dashed", + "label": "JVM process exits →\nexecute_task() returns the exit code", + }, +] + + +def _label(title: str, sub: str | None = None) -> str: + html = f"<{title}" + if sub: + safe = sub.replace("\n", "
") + html += f'
{safe}' + return html + ">" + + +def _header(g, pid: str, title: str, sub: str, theme: tuple[str, str]) -> None: + fill, border = theme + g.node( + f"{pid}__h", + label=_label(title, sub), + shape="box", + style="rounded,filled", + fillcolor=fill, + color=border, + penwidth="2", + margin="0.24,0.14", + ) + + +def _activation(g, node_id: str, num: int, text: str, theme: tuple[str, str]) -> None: + fill, border = theme + g.node( + node_id, + label=_label(f"{num}", text), + shape="box", + style="rounded,filled", + fillcolor=fill, + color=border, + penwidth="2", + margin="0.2,0.12", + ) + + +def _waypoint(g, node_id: str) -> None: + g.node(node_id, shape="point", width="0.02", color=LIFELINE) + + +def _edge_label(num: int, text: str) -> str: + # Pad the description on every side: blank lines above and below keep it clear + # of the arrow line, and leading/trailing spaces on each line keep it clear of + # the lifelines on the left and right. + lines = f"{num} · {text}".split("\n") + padded = "\n".join(f" {line} " for line in lines) + return f" \n{padded}\n " + + +def _build_sequence(g) -> None: + ids = [p[0] for p in PARTICIPANTS] + + # --- participant headers, ordered left-to-right on the top rank ---------- # + with g.subgraph() as top: + top.attr(rank="same") + for pid, title, sub, theme in PARTICIPANTS: + _header(top, pid, title, sub, theme) + for a, b in zip(ids, ids[1:]): + g.edge(f"{a}__h", f"{b}__h", style="invis") + + # --- one rank per step; way-points on every lifeline, box on the actor --- # + for i, step in enumerate(STEPS): + with g.subgraph() as row: + row.attr(rank="same") + for pid, _, _, theme in PARTICIPANTS: + nid = f"{pid}__{i}" + if step["kind"] == "self" and step["actor"] == pid: + _activation(row, nid, i + 1, step["text"], step.get("theme", theme)) + else: + _waypoint(row, nid) + + # --- dashed vertical lifelines through the way-points -------------------- # + for pid, *_ in PARTICIPANTS: + chain = [f"{pid}__h"] + [f"{pid}__{i}" for i in range(len(STEPS))] + for a, b in zip(chain, chain[1:]): + g.edge(a, b, style="dashed", arrowhead="none", color=LIFELINE, penwidth="1.3") + + # --- message arrows (horizontal, do not constrain ranking) --------------- # + for i, step in enumerate(STEPS): + if step["kind"] != "msg": + continue + g.edge( + f"{step['from']}__{i}", + f"{step['to']}__{i}", + xlabel=_edge_label(i + 1, step["label"]), + color=step["color"], + fontcolor=step["color"], + style=step.get("style", "solid"), + arrowhead="vee", + penwidth="2", + constraint="false", + ) + + _legend(g) + + +def _legend(g) -> None: + entries = [ + ("lg_sched", "Scheduler → Supervisor", SCHED), + ("lg_coord", "Coordinator layer (CoordinatorManager / JavaCoordinator)", COORD), + ("lg_sup", "Supervisor → JVM / Execution API", SUP), + ("lg_jvm", "JVM (user code) → Supervisor (loopback TCP)", JVM), + ("lg_api", "Execution API → Supervisor (response)", API), + ] + with g.subgraph(name="cluster_legend") as legend: + legend.attr( + label="Arrow color = sender · steps 9–12 repeat per Connection / Variable / XCom lookup", + labelloc="t", + style="rounded,filled", + fillcolor="#fafafa", + color="#b0bec5", + fontsize="12", + fontname="Helvetica-Bold", + margin="12", + ) + for nid, text, theme in entries: + fill, border = theme + legend.node( + nid, + label=_label(text), + shape="box", + style="rounded,filled", + fillcolor=fill, + color=border, + penwidth="2", + margin="0.2,0.1", + ) + for a, b in zip([e[0] for e in entries], [e[0] for e in entries][1:]): + legend.edge(a, b, style="invis") + # Anchor the legend below the diagram, on the left. + g.edge(f"sched__{len(STEPS) - 1}", "lg_sched", style="invis") + + +def generate_java_sdk_execution_sequence_diagram(): + image_file = MY_DIR / f"{MY_FILENAME}.png" + console.print(f"[bright_blue]Generating sequence image {image_file}") + + g = graphviz.Digraph("java_sdk_execution_sequence") + g.attr( + rankdir="TB", + splines="line", + forcelabels="true", + nodesep="1.3", + ranksep="1.2", + pad="0.5", + bgcolor="white", + fontname="Helvetica", + ) + g.attr("node", fontname="Helvetica", fontsize="13", fontcolor="#102027") + g.attr("edge", fontname="Helvetica", fontsize="10", penwidth="2", color="#546e7a") + + _build_sequence(g) + + g.render(outfile=str(image_file), format="png", cleanup=True) + console.print(f"[green]Generated sequence image {image_file}") + + +if __name__ == "__main__": + generate_java_sdk_execution_sequence_diagram() diff --git a/airflow-core/docs/img/diagram_native_language_sdk_architecture.md5sum b/airflow-core/docs/img/diagram_native_language_sdk_architecture.md5sum new file mode 100644 index 0000000000000..8149286f36321 --- /dev/null +++ b/airflow-core/docs/img/diagram_native_language_sdk_architecture.md5sum @@ -0,0 +1 @@ +d7f0c11f0b175a82b2bded558ebfa0f1 diff --git a/airflow-core/docs/img/diagram_native_language_sdk_architecture.png b/airflow-core/docs/img/diagram_native_language_sdk_architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..b7e0a71e06db5e4b426a21b089f9aec186df8584 GIT binary patch literal 146251 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