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

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


The following commit(s) were added to refs/heads/main by this push:
     new 69f98974344 Look up task logs by task try UUID (#74204)
69f98974344 is described below

commit 69f98974344dec7de5a70c91a9395bc72163687c
Author: Ash Berlin-Taylor <[email protected]>
AuthorDate: Mon Oct 5 23:44:34 2026 +0100

    Look up task logs by task try UUID (#74204)
    
    These handlers previously identified a task's logs by coordinates (dag,
    task, run, map index, try number). Once a task can run in several loop
    passes that share those coordinates, the identity has to come from
    something that is unique per attempt, and with the ownership change
    every try already has its own UUID.
    
    Elasticsearch and OpenSearch entries now carry the task instance's id as
    ``ti_id`` and Cloud Logging entries carry it as a label of the same
    name, next to the existing log_id or labels, so the human-readable
    identity and any Kibana or Logs Explorer, etc. queries built on them
    continue to work. Readers match on that id alone, or fall back to the
    log_id or labels for entries that have no ``ti_id``, so logs written
    before this change stay readable without reindexing.
    
    ``ti_id`` is where the task process has bound the UUID in to its own log
    lines since 3.3, so entries from 3.3 onward already match. Nothing
    earlier wrote it, and most lines the supervisor logs itself do not
    carry it either, so the Elasticsearch and OpenSearch uploaders add it to
    every line they ship.
    
    Readers only match on the id from Airflow 3.4. Before then clearing a
    task instance gives the live row a new id, so it may no longer be the id
    its logs were written under, and earlier versions keep matching on
    log_id or labels alone. Downloading every try on Google Cloud Logging
    drops the id condition just as it already drops the try number.
---
 .../kubernetes/executors/kubernetes_executor.py    |   3 +
 .../executors/kubernetes_executor_types.py         |   1 +
 .../executors/kubernetes_executor_utils.py         |   2 +
 .../executors/test_kubernetes_executor.py          |  29 ++++++
 providers/elasticsearch/docs/logging/index.rst     |   5 +
 .../providers/elasticsearch/log/es_task_handler.py |  43 +++++++-
 .../providers/elasticsearch/version_compat.py      |   2 +
 .../elasticsearch/log/test_es_remote_log_io.py     |  32 ++++++
 .../unit/elasticsearch/log/test_es_task_handler.py |  44 +++++++-
 .../google/cloud/log/cloud_logging_task_handler.py |  26 ++++-
 .../src/airflow/providers/google/version_compat.py |   9 +-
 .../cloud/log/test_cloud_logging_task_handler.py   | 112 +++++++++++++++++----
 .../providers/opensearch/log/os_task_handler.py    |  43 ++++++--
 .../airflow/providers/opensearch/version_compat.py |   2 +
 .../opensearch/log/test_os_remote_log_io.py        |  32 ++++++
 .../unit/opensearch/log/test_os_task_handler.py    |  48 ++++++++-
 16 files changed, 397 insertions(+), 36 deletions(-)

diff --git 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
index 819c404da15..f19a64b08dd 100644
--- 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
+++ 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
@@ -52,6 +52,7 @@ from 
airflow.providers.cncf.kubernetes.executors.kubernetes_executor_types impor
     ADOPTED,
     POD_EXECUTOR_DONE_KEY,
     TASK_INSTANCE_ID_ANNOTATION,
+    TASK_INSTANCE_ID_LABEL,
     FailureDetails,
     KubernetesJob,
     KubernetesResults,
@@ -1042,6 +1043,8 @@ class KubernetesExecutor(BaseExecutor):
                 run_id=ti.run_id,
                 airflow_worker=ti.queued_by_job_id,
             )
+            if self.supports_task_instance_uuid:
+                selector += f",{TASK_INSTANCE_ID_LABEL}={ti.id}"
             namespace = self._get_pod_namespace(ti)
             pod_list = client.list_namespaced_pod(
                 namespace=namespace,
diff --git 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_types.py
 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_types.py
index 3f41f398228..2eea8f07125 100644
--- 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_types.py
+++ 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_types.py
@@ -102,6 +102,7 @@ So we want events on a revoked pod to be ignored.
 
 
 TASK_INSTANCE_ID_ANNOTATION = "task_instance_id"
+TASK_INSTANCE_ID_LABEL = "ti_id"
 
 
 def task_instance_id_from_pod(pod: k8s.V1Pod) -> UUID | None:
diff --git 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
index ce574867191..576a6fe796f 100644
--- 
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
+++ 
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor_utils.py
@@ -38,6 +38,7 @@ from 
airflow.providers.cncf.kubernetes.executors.kubernetes_executor_types impor
     POD_EXECUTOR_DONE_KEY,
     POD_REVOKED_KEY,
     TASK_INSTANCE_ID_ANNOTATION,
+    TASK_INSTANCE_ID_LABEL,
     FailureDetails,
     KubernetesJob,
     KubernetesResults,
@@ -658,6 +659,7 @@ class AirflowKubernetesScheduler(LoggingMixin):
         )
         if self.supports_task_instance_uuid and isinstance(key, 
TaskInstanceUuid):
             pod.metadata.annotations[TASK_INSTANCE_ID_ANNOTATION] = str(key)
+            pod.metadata.labels[TASK_INSTANCE_ID_LABEL] = str(key)
         # Reconcile the pod generated by the Operator and the Pod
         # generated by the .cfg file
         self.log.info(
diff --git 
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
index 7baff0322c5..84e03ce35fb 100644
--- 
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
+++ 
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
@@ -47,6 +47,7 @@ from 
airflow.providers.cncf.kubernetes.executors.kubernetes_executor import (
 )
 from airflow.providers.cncf.kubernetes.executors.kubernetes_executor_types 
import (
     ADOPTED,
+    TASK_INSTANCE_ID_LABEL,
     KubernetesJob,
     KubernetesResults,
     KubernetesWatch,
@@ -4366,6 +4367,7 @@ class TestKubernetesExecutorUuid:
         )
 
         assert pod.metadata.annotations["task_instance_id"] == 
str(uuid_workload.ti.id)
+        assert pod.metadata.labels[TASK_INSTANCE_ID_LABEL] == 
str(uuid_workload.ti.id)
         assert pod.metadata.annotations["dag_id"] == "uuid_dag"
         assert pod.metadata.annotations["try_number"] == "1"
 
@@ -4396,6 +4398,33 @@ class TestKubernetesExecutorUuid:
         assert statement.compile().params == {"id_1": uuid_workload.ti.id}
         assert state == State.RESTARTING
 
+    
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+    def test_get_streaming_task_log_selects_pod_by_attempt_uuid_label(self, 
mock_get_kube_client):
+        ti_id = uuid4()
+        mock_kube_client = mock_get_kube_client.return_value
+        mock_kube_client.list_namespaced_pod.return_value.items = [
+            k8s.V1Pod(metadata=k8s.V1ObjectMeta(name="current"))
+        ]
+        mock_kube_client.read_namespaced_pod_log.return_value = [b"a_"]
+        ti = mock.MagicMock(
+            id=ti_id,
+            try_number=2,
+            dag_id="test_k8s_log_dag",
+            task_id="test_task",
+            map_index=-1,
+            run_id="test_run",
+            queued_by_job_id=None,
+            hostname="",
+            executor_config={},
+        )
+
+        messages, _ = KubernetesExecutor().get_streaming_task_log(ti=ti, 
try_number=2)
+
+        assert messages[-1] == "Found logs through kube API"
+        label_selector = 
mock_kube_client.list_namespaced_pod.call_args.kwargs["label_selector"]
+        assert f"{TASK_INSTANCE_ID_LABEL}={ti_id}" in label_selector.split(",")
+        assert 
mock_kube_client.read_namespaced_pod_log.call_args.kwargs["name"] == "current"
+
     def test_revoke_selects_uuid_among_pods_with_reused_coordinates(self, 
uuid_workload, mocker):
         executor = KubernetesExecutor()
         executor.kube_scheduler = 
mocker.create_autospec(AirflowKubernetesScheduler, instance=True)
diff --git a/providers/elasticsearch/docs/logging/index.rst 
b/providers/elasticsearch/docs/logging/index.rst
index 2e8d732ea3f..8fd3883f705 100644
--- a/providers/elasticsearch/docs/logging/index.rst
+++ b/providers/elasticsearch/docs/logging/index.rst
@@ -294,6 +294,11 @@ The following fields are recognized and displayed by the 
Airflow UI when present
    * - ``host``
      - The hostname of the worker that produced the log. Used to group log 
lines by source.
      - Field name can be customized with the ``host_field`` handler parameter.
+   * - ``ti_id``
+     - UUID of the task try that produced the log line.
+     - Written by the Airflow 3 task process, so a log shipper forwards it 
from the task's JSON output
+       without extra configuration. From Airflow 3.4 logs are read by this 
field, falling back to
+       ``log_id`` for documents that lack it.
 
 **Field mappings**
 
diff --git 
a/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
 
b/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
index 2fd81a541c5..c70e0800137 100644
--- 
a/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
+++ 
b/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
@@ -49,7 +49,11 @@ from airflow.providers.common.compat.sdk import conf, 
timezone
 from airflow.providers.elasticsearch._compat import apply_compat_with
 from airflow.providers.elasticsearch.log.es_json_formatter import 
ElasticsearchJSONFormatter
 from airflow.providers.elasticsearch.log.es_response import 
ElasticSearchResponse, Hit, resolve_nested
-from airflow.providers.elasticsearch.version_compat import AIRFLOW_V_3_0_PLUS, 
AIRFLOW_V_3_2_PLUS
+from airflow.providers.elasticsearch.version_compat import (
+    AIRFLOW_V_3_0_PLUS,
+    AIRFLOW_V_3_2_PLUS,
+    AIRFLOW_V_3_4_PLUS,
+)
 from airflow.utils.log.file_task_handler import FileTaskHandler
 from airflow.utils.log.logging_mixin import ExternalLoggingMixin, LoggingMixin
 from airflow.utils.state import TaskInstanceState
@@ -226,6 +230,31 @@ def _render_log_id(log_id_template: str, ti: TaskInstance 
| TaskInstanceKey, try
     )
 
 
+def _get_ti_id_fields(ti: TaskInstance | TaskInstanceKey) -> dict[str, str]:
+    # Before 3.4 a try can reuse the previous try's id, so only log_id 
identifies it.
+    if not AIRFLOW_V_3_4_PLUS:
+        return {}
+    return {"ti_id": str(ti_id)} if (ti_id := getattr(ti, "id", None)) else {}
+
+
+def _build_log_query(log_id: str, ti: RuntimeTI) -> list[dict[str, Any]]:
+    log_id_match = {"match_phrase": {"log_id": log_id}}
+    # Before 3.4 a cleared task instance gets a new id, which can differ from 
the id its logs were written under.
+    if not AIRFLOW_V_3_4_PLUS:
+        return [log_id_match]
+    return [
+        {
+            "bool": {
+                "should": [
+                    {"match_phrase": {"ti_id": str(ti.id)}},
+                    {"bool": {"must": [log_id_match], "must_not": {"exists": 
{"field": "ti_id"}}}},
+                ],
+                "minimum_should_match": 1,
+            }
+        }
+    ]
+
+
 def _clean_date(value: datetime | None) -> str:
     """
     Clean up a date value so that it is safe to query in elasticsearch by 
removing reserved characters.
@@ -761,21 +790,24 @@ class ElasticsearchRemoteLogIO(LoggingMixin):  # noqa: 
D101
             local_loc = self.base_log_folder.joinpath(path)
 
         log_id = _render_log_id(self.log_id_template, ti, ti.try_number)  # 
type: ignore[arg-type]
+        ti_id_fields = _get_ti_id_fields(ti)  # type: ignore[arg-type]
         if local_loc.is_file() and self.write_stdout:
             # Intentionally construct the log_id and offset field
 
-            log_lines = self._parse_raw_log(local_loc.read_text(), log_id)
+            log_lines = self._parse_raw_log(local_loc.read_text(), log_id, 
ti_id_fields)
             for line in log_lines:
                 sys.stdout.write(json.dumps(line) + "\n")
                 sys.stdout.flush()
 
         if local_loc.is_file() and self.write_to_es:
-            log_lines = self._parse_raw_log(local_loc.read_text(), log_id)
+            log_lines = self._parse_raw_log(local_loc.read_text(), log_id, 
ti_id_fields)
             success = self._write_to_es(log_lines)
             if success and self.delete_local_copy:
                 shutil.rmtree(os.path.dirname(local_loc))
 
-    def _parse_raw_log(self, log: str, log_id: str) -> list[dict[str, Any]]:
+    def _parse_raw_log(
+        self, log: str, log_id: str, extra_fields: dict[str, str] | None = None
+    ) -> list[dict[str, Any]]:
         logs = log.split("\n")
         parsed_logs = []
         offset = 1
@@ -801,6 +833,7 @@ class ElasticsearchRemoteLogIO(LoggingMixin):  # noqa: D101
             log_dict.update(
                 {
                     "log_id": log_id,
+                    **(extra_fields or {}),
                     self.offset_field: offset,
                 }
             )
@@ -883,7 +916,7 @@ class ElasticsearchRemoteLogIO(LoggingMixin):  # noqa: D101
         query: dict[Any, Any] = {
             "bool": {
                 "filter": [{"range": {self.offset_field: {"gt": 
int(offset)}}}],
-                "must": [{"match_phrase": {"log_id": log_id}}],
+                "must": _build_log_query(log_id, ti),
             }
         }
 
diff --git 
a/providers/elasticsearch/src/airflow/providers/elasticsearch/version_compat.py 
b/providers/elasticsearch/src/airflow/providers/elasticsearch/version_compat.py
index 806dbac18d2..8c63b0494d7 100644
--- 
a/providers/elasticsearch/src/airflow/providers/elasticsearch/version_compat.py
+++ 
b/providers/elasticsearch/src/airflow/providers/elasticsearch/version_compat.py
@@ -36,10 +36,12 @@ AIRFLOW_V_3_0_PLUS = get_base_airflow_version_tuple() >= 
(3, 0, 0)
 AIRFLOW_V_3_1_PLUS: bool = get_base_airflow_version_tuple() >= (3, 1, 0)
 AIRFLOW_V_3_2_PLUS: bool = get_base_airflow_version_tuple() >= (3, 2, 0)
 AIRFLOW_V_3_3_PLUS: bool = get_base_airflow_version_tuple() >= (3, 3, 0)
+AIRFLOW_V_3_4_PLUS: bool = get_base_airflow_version_tuple() >= (3, 4, 0)
 
 __all__ = [
     "AIRFLOW_V_3_0_PLUS",
     "AIRFLOW_V_3_1_PLUS",
     "AIRFLOW_V_3_2_PLUS",
     "AIRFLOW_V_3_3_PLUS",
+    "AIRFLOW_V_3_4_PLUS",
 ]
diff --git 
a/providers/elasticsearch/tests/integration/elasticsearch/log/test_es_remote_log_io.py
 
b/providers/elasticsearch/tests/integration/elasticsearch/log/test_es_remote_log_io.py
index e6da3dc7514..8ba26091deb 100644
--- 
a/providers/elasticsearch/tests/integration/elasticsearch/log/test_es_remote_log_io.py
+++ 
b/providers/elasticsearch/tests/integration/elasticsearch/log/test_es_remote_log_io.py
@@ -39,6 +39,7 @@ class _MockTI:
     run_id: str = "integration_test_run"
     try_number: int = 1
     map_index: int = -1
+    id: uuid.UUID = dataclasses.field(default_factory=uuid.uuid4)
 
 
 @pytest.mark.integration("elasticsearch")
@@ -145,3 +146,34 @@ class TestElasticsearchRemoteLogIOIntegration:
         log_entry = json.loads(log_messages[0])
         assert "error_detail" in log_entry
         assert log_entry["error_detail"] == error_detail
+
+    @pytest.mark.parametrize(
+        ("written", "reader", "expected"),
+        [
+            pytest.param(["legacy"], "x", ["legacy"], 
id="pre-upgrade-logs-stay-readable"),
+            pytest.param(["x", "y"], "x", ["x"], 
id="post-upgrade-reader-sees-only-its-try"),
+            pytest.param(["x", "y"], "y", ["y"], id="post-upgrade-other-try"),
+            pytest.param(["legacy", "x", "y"], "x", ["legacy", "x"], 
id="mixed-index"),
+        ],
+    )
+    @patch(
+        "airflow.providers.elasticsearch.log.es_task_handler.TASK_LOG_FIELDS",
+        ["message"],
+    )
+    def test_read_scopes_to_try_and_keeps_pre_upgrade_logs(self, tmp_path, 
written, reader, expected):
+        """Tries sharing one log_id stay separate, and entries without ti_id 
stay readable."""
+        try_ids = {"x": uuid.uuid4(), "y": uuid.uuid4()}
+        log_id = _render_log_id(self.elasticsearch_io.log_id_template, 
_MockTI(), 1)
+        for name in written:
+            if name == "legacy":
+                legacy_entry = {"message": name, "log_id": log_id, "offset": 1}
+                self.elasticsearch_io.client.index(index=self.target_index, 
document=legacy_entry)
+                continue
+            log_file = tmp_path / f"{name}.log"
+            log_file.write_text(json.dumps({"message": name}) + "\n")
+            self.elasticsearch_io.upload(log_file, _MockTI(id=try_ids[name]))
+        self.elasticsearch_io.client.indices.refresh(index=self.target_index)
+
+        _, log_messages = self.elasticsearch_io.read("", 
_MockTI(id=try_ids[reader]))
+
+        assert sorted(json.loads(message)["event"] for message in 
log_messages) == sorted(expected)
diff --git 
a/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py 
b/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
index 5fa6f1ad6ef..2517975349c 100644
--- 
a/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
+++ 
b/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
@@ -24,9 +24,11 @@ import os
 import re
 from io import StringIO
 from pathlib import Path
+from types import SimpleNamespace
 from unittest import mock
 from unittest.mock import Mock, patch
 from urllib.parse import quote
+from uuid import UUID, uuid4
 
 import elasticsearch
 import pendulum
@@ -41,8 +43,10 @@ from airflow.providers.elasticsearch.log.es_task_handler 
import (
     ElasticsearchRemoteLogIO,
     ElasticsearchTaskHandler,
     _build_log_fields,
+    _build_log_query,
     _clean_date,
     _format_error_detail,
+    _get_ti_id_fields,
     _render_log_id,
     _safe_build_structured_log_message,
     _strip_userinfo,
@@ -57,6 +61,41 @@ from tests_common.test_utils.db import clear_db_dags, 
clear_db_runs
 from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
 
 
[email protected](
+    ("is_airflow_3_4_plus", "expected"),
+    [(False, {}), (True, {"ti_id": "some-ti-id"})],
+)
+def test_ti_id_is_only_written_from_airflow_3_4(is_airflow_3_4_plus, expected):
+    ti = SimpleNamespace(id="some-ti-id")
+
+    with 
patch("airflow.providers.elasticsearch.log.es_task_handler.AIRFLOW_V_3_4_PLUS", 
is_airflow_3_4_plus):
+        assert _get_ti_id_fields(ti) == expected
+
+
[email protected]("is_airflow_3_4_plus", [False, True])
+def test_log_query_matches_ti_id_or_documents_without_it(is_airflow_3_4_plus):
+    ti = SimpleNamespace(id=uuid4())
+    log_id_match = {"match_phrase": {"log_id": "some-log-id"}}
+
+    with 
patch("airflow.providers.elasticsearch.log.es_task_handler.AIRFLOW_V_3_4_PLUS", 
is_airflow_3_4_plus):
+        must = _build_log_query("some-log-id", ti)
+
+    if not is_airflow_3_4_plus:
+        assert must == [log_id_match]
+        return
+    assert must == [
+        {
+            "bool": {
+                "should": [
+                    {"match_phrase": {"ti_id": str(ti.id)}},
+                    {"bool": {"must": [log_id_match], "must_not": {"exists": 
{"field": "ti_id"}}}},
+                ],
+                "minimum_should_match": 1,
+            }
+        }
+    ]
+
+
 @dataclasses.dataclass
 class _MockTI:
     dag_id: str = "dag_for_testing_es_log_handler"
@@ -64,6 +103,7 @@ class _MockTI:
     run_id: str = "run_for_testing_es_log_handler"
     try_number: int = 1
     map_index: int = -1
+    id: UUID = dataclasses.field(default_factory=uuid4)
 
 
 def get_ti(dag_id, task_id, run_id, logical_date, create_task_instance):
@@ -706,6 +746,7 @@ class TestElasticsearchRemoteLogIO:
         file_path.write_text("\n".join(json.dumps(log) for log in sample_logs) 
+ "\n")
         return file_path
 
+    
@patch("airflow.providers.elasticsearch.log.es_task_handler.AIRFLOW_V_3_4_PLUS",
 True)
     def test_write_to_stdout(self, tmp_json_file, ti, capsys):
         self.elasticsearch_io.write_to_es = False
         self.elasticsearch_io.upload(tmp_json_file, ti)
@@ -714,6 +755,7 @@ class TestElasticsearchRemoteLogIO:
         stdout_lines = captured.out.strip().splitlines()
         log_entries = [json.loads(line) for line in stdout_lines]
         assert [entry["message"] for entry in log_entries] == ["start", 
"processing", "end"]
+        assert {entry["ti_id"] for entry in log_entries} == {str(ti.id)}
 
     def test_invalid_task_log_file_path(self, ti):
         with (
@@ -816,7 +858,7 @@ class TestElasticsearchRemoteLogIO:
         query = {
             "bool": {
                 "filter": [{"range": {self.elasticsearch_io.offset_field: 
{"gt": 2}}}],
-                "must": [{"match_phrase": {"log_id": log_id}}],
+                "must": _build_log_query(log_id, ti),
             }
         }
 
diff --git 
a/providers/google/src/airflow/providers/google/cloud/log/cloud_logging_task_handler.py
 
b/providers/google/src/airflow/providers/google/cloud/log/cloud_logging_task_handler.py
index 3bc9be0274b..1a9a2b296c7 100644
--- 
a/providers/google/src/airflow/providers/google/cloud/log/cloud_logging_task_handler.py
+++ 
b/providers/google/src/airflow/providers/google/cloud/log/cloud_logging_task_handler.py
@@ -46,7 +46,7 @@ from airflow.providers.common.compat.sdk import conf
 from airflow.providers.google.cloud.utils.credentials_provider import 
get_credentials_and_project_id
 from airflow.providers.google.common.consts import CLIENT_INFO
 from airflow.providers.google.common.hooks.base_google import GoogleBaseHook
-from airflow.providers.google.version_compat import AIRFLOW_V_3_0_PLUS
+from airflow.providers.google.version_compat import AIRFLOW_V_3_0_PLUS, 
AIRFLOW_V_3_4_PLUS
 from airflow.utils.log.file_task_handler import FileTaskHandler
 from airflow.utils.log.logging_mixin import LoggingMixin
 
@@ -82,6 +82,7 @@ LABEL_TASK_ID = "task_id"
 LABEL_DAG_ID = "dag_id"
 LABEL_LOGICAL_DATE = "logical_date" if AIRFLOW_V_3_0_PLUS else "execution_date"
 LABEL_TRY_NUMBER = "try_number"
+LABEL_TI_ID = "ti_id"
 
 
 @attrs.define(kw_only=True)
@@ -209,6 +210,8 @@ class CloudLoggingRemoteLogIO(LoggingMixin):
             if ti:
                 labels.update(_task_instance_to_labels(ti))
             else:
+                if ti_id := event.get("ti_id"):
+                    labels[LABEL_TI_ID] = str(ti_id)
                 if dag_id := event.get("dag_id"):
                     labels[LABEL_DAG_ID] = str(dag_id)
                 if task_id := event.get("task_id"):
@@ -269,8 +272,18 @@ class CloudLoggingRemoteLogIO(LoggingMixin):
         for key, value in self.resource.labels.items():
             
log_filters.append(f"resource.labels.{escape_label_key(key)}={escape_label_value(value)}")
 
-        for key, value in ti_labels.items():
-            
log_filters.append(f"labels.{escape_label_key(key)}={escape_label_value(value)}")
+        label_conditions = {
+            key: f"labels.{escape_label_key(key)}={escape_label_value(value)}"
+            for key, value in ti_labels.items()
+        }
+        ti_id_condition = label_conditions.pop(LABEL_TI_ID, None)
+        # Before 3.4 a cleared task instance gets a new id, which can differ 
from the id its logs were written under.
+        if ti_id_condition and AIRFLOW_V_3_4_PLUS:
+            # Entries written before the ``ti_id`` label existed are matched 
by the other labels.
+            legacy_conditions = [*label_conditions.values(), f"NOT 
labels.{LABEL_TI_ID}:*"]
+            log_filters.append(f"({ti_id_condition} OR ({' AND 
'.join(legacy_conditions)}))")
+        else:
+            log_filters.extend(label_conditions.values())
         return "\n".join(log_filters)
 
     def read_logs(
@@ -317,7 +330,7 @@ class CloudLoggingRemoteLogIO(LoggingMixin):
 
 def _task_instance_to_labels(ti) -> dict[str, str]:
     """Convert a task instance to Cloud Logging labels."""
-    return {
+    labels = {
         LABEL_TASK_ID: ti.task_id,
         LABEL_DAG_ID: ti.dag_id,
         LABEL_LOGICAL_DATE: str(ti.logical_date.isoformat())
@@ -325,6 +338,10 @@ def _task_instance_to_labels(ti) -> dict[str, str]:
         else str(ti.execution_date.isoformat()),
         LABEL_TRY_NUMBER: str(ti.try_number),
     }
+    # Before 3.4 a try can reuse the previous try's id, so only the other 
labels identify it.
+    if AIRFLOW_V_3_4_PLUS and (ti_id := getattr(ti, "id", None)):
+        labels[LABEL_TI_ID] = str(ti_id)
+    return labels
 
 
 class CloudLoggingTaskHandler(logging.Handler):
@@ -472,6 +489,7 @@ class CloudLoggingTaskHandler(logging.Handler):
             ti_labels[LABEL_TRY_NUMBER] = str(try_number)
         else:
             del ti_labels[LABEL_TRY_NUMBER]
+            ti_labels.pop(LABEL_TI_ID, None)
 
         log_filter = self.io.prepare_log_filter(ti_labels)
         next_page_token = metadata.get("next_page_token", None)
diff --git a/providers/google/src/airflow/providers/google/version_compat.py 
b/providers/google/src/airflow/providers/google/version_compat.py
index 57d8840c9ef..bb32aa84ff2 100644
--- a/providers/google/src/airflow/providers/google/version_compat.py
+++ b/providers/google/src/airflow/providers/google/version_compat.py
@@ -36,6 +36,7 @@ AIRFLOW_V_3_0_PLUS = get_base_airflow_version_tuple() >= (3, 
0, 0)
 AIRFLOW_V_3_1_PLUS = get_base_airflow_version_tuple() >= (3, 1, 0)
 # BaseTrigger.on_kill() was introduced in 3.3.0; use this flag to guard 
trigger migrations
 AIRFLOW_V_3_3_PLUS = get_base_airflow_version_tuple() >= (3, 3, 0)
+AIRFLOW_V_3_4_PLUS = get_base_airflow_version_tuple() >= (3, 4, 0)
 
 # Version-compatible imports
 # BaseOperator: Use 3.1+ due to xcom_push method missing in SDK BaseOperator 
3.0.x
@@ -49,4 +50,10 @@ else:
 
 
 # Explicitly export these imports to protect them from being removed by linters
-__all__ = ["AIRFLOW_V_3_0_PLUS", "AIRFLOW_V_3_1_PLUS", "AIRFLOW_V_3_3_PLUS", 
"BaseOperator"]
+__all__ = [
+    "AIRFLOW_V_3_0_PLUS",
+    "AIRFLOW_V_3_1_PLUS",
+    "AIRFLOW_V_3_3_PLUS",
+    "AIRFLOW_V_3_4_PLUS",
+    "BaseOperator",
+]
diff --git 
a/providers/google/tests/unit/google/cloud/log/test_cloud_logging_task_handler.py
 
b/providers/google/tests/unit/google/cloud/log/test_cloud_logging_task_handler.py
index b360f9c2525..bdbb9889f77 100644
--- 
a/providers/google/tests/unit/google/cloud/log/test_cloud_logging_task_handler.py
+++ 
b/providers/google/tests/unit/google/cloud/log/test_cloud_logging_task_handler.py
@@ -20,6 +20,7 @@ import logging
 import warnings
 from contextlib import nullcontext
 from pathlib import Path
+from types import SimpleNamespace
 from unittest import mock
 from unittest.mock import PropertyMock
 from urllib.parse import parse_qs, urlsplit
@@ -33,12 +34,32 @@ from airflow.providers.common.compat.sdk import timezone
 from airflow.providers.google.cloud.log.cloud_logging_task_handler import (
     CloudLoggingRemoteLogIO,
     CloudLoggingTaskHandler,
+    _task_instance_to_labels,
 )
 from airflow.utils.state import TaskInstanceState
 
 from tests_common.test_utils.config import conf_vars
 from tests_common.test_utils.db import clear_db_dags, clear_db_runs
-from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS, 
AIRFLOW_V_3_4_PLUS
+
+
[email protected](
+    ("is_airflow_3_4_plus", "expect_ti_id"),
+    [(False, False), (True, True)],
+)
+def test_ti_id_label_is_only_written_from_airflow_3_4(is_airflow_3_4_plus, 
expect_ti_id):
+    now = timezone.utcnow()
+    ti = SimpleNamespace(
+        id="some-ti-id", task_id="t", dag_id="d", try_number=1, 
logical_date=now, execution_date=now
+    )
+
+    with mock.patch(
+        
"airflow.providers.google.cloud.log.cloud_logging_task_handler.AIRFLOW_V_3_4_PLUS",
+        is_airflow_3_4_plus,
+    ):
+        labels = _task_instance_to_labels(ti)
+
+    assert ("ti_id" in labels) is expect_ti_id
 
 
 def _create_list_log_entries_response_mock(messages, token):
@@ -289,6 +310,7 @@ class TestCloudLoggingRemoteLogIO:
                 "run_id": "test_run_id",
                 "try_number": 2,
                 "map_index": -1,
+                "ti_id": "2a4ab344-e2b8-41c9-808f-2411659ba64f",
             }
 
             result = proc(logger, "info", event)
@@ -300,6 +322,7 @@ class TestCloudLoggingRemoteLogIO:
 
             labels = kwargs.get("labels", {})
             assert labels == {
+                "ti_id": event["ti_id"],
                 "dag_id": "test_dag_id",
                 "task_id": "test_task_id",
                 "run_id": "test_run_id",
@@ -325,6 +348,42 @@ class TestCloudLoggingRemoteLogIO:
         assert 'labels.task_id="test_task"' in log_filter
         assert 'labels.dag_id="test_dag"' in log_filter
 
+    @mock.patch(
+        
"airflow.providers.google.cloud.log.cloud_logging_task_handler.get_credentials_and_project_id"
+    )
+    @pytest.mark.parametrize(
+        ("is_airflow_3_4_plus", "expected_conditions"),
+        [
+            pytest.param(
+                False,
+                ['labels.task_id="test_task"', 'labels.dag_id="test_dag"', 
'labels.try_number="1"'],
+                id="before-3.4",
+            ),
+            pytest.param(
+                True,
+                [
+                    '(labels.ti_id="abc" OR (labels.task_id="test_task" AND 
labels.dag_id="test_dag" '
+                    'AND labels.try_number="1" AND NOT labels.ti_id:*))'
+                ],
+                id="3.4-or-later",
+            ),
+        ],
+    )
+    def test_prepare_log_filter_matches_ti_id_or_legacy_labels(
+        self, mock_get_creds_and_project_id, is_airflow_3_4_plus, 
expected_conditions
+    ):
+        mock_get_creds_and_project_id.return_value = ("creds", "project_id")
+
+        with mock.patch(
+            
"airflow.providers.google.cloud.log.cloud_logging_task_handler.AIRFLOW_V_3_4_PLUS",
+            is_airflow_3_4_plus,
+        ):
+            log_filter = self.io.prepare_log_filter(
+                {"task_id": "test_task", "dag_id": "test_dag", "try_number": 
"1", "ti_id": "abc"}
+            )
+
+        assert log_filter.splitlines()[2:] == expected_conditions
+
     @mock.patch(
         
"airflow.providers.google.cloud.log.cloud_logging_task_handler.get_credentials_and_project_id"
     )
@@ -491,6 +550,15 @@ class TestCloudLoggingHandlerTask:
         clear_db_runs()
         clear_db_dags()
 
+    def _ti_id_labels(self) -> dict[str, str]:
+        return {"ti_id": str(self.ti.id)} if AIRFLOW_V_3_4_PLUS else {}
+
+    def _expect_label_filter(self, *label_conditions: str) -> str:
+        if not AIRFLOW_V_3_4_PLUS:
+            return "\n".join(label_conditions)
+        legacy_conditions = " AND ".join([*label_conditions, "NOT 
labels.ti_id:*"])
+        return f'(labels.ti_id="{self.ti.id}" OR ({legacy_conditions}))'
+
     def _setup_handler(self, **handler_kwargs):
         self.transport_mock = mock.MagicMock()
         handler_kwargs = {"transport": self.transport_mock, **handler_kwargs}
@@ -518,6 +586,7 @@ class TestCloudLoggingHandlerTask:
             "dag_id": self.DAG_ID,
             date_key: "2016-01-01T00:00:00+00:00",
             "try_number": "1",
+            **self._ti_id_labels(),
         }
         resource = Resource(type="global", labels={})
         self.transport_mock.return_value.send.assert_called_once_with(
@@ -545,6 +614,7 @@ class TestCloudLoggingHandlerTask:
             "dag_id": self.DAG_ID,
             date_key: "2016-01-01T00:00:00+00:00",
             "try_number": "1",
+            **self._ti_id_labels(),
             "product.googleapis.com/task_id": "test-value",
         }
         resource = Resource(type="global", labels={})
@@ -636,10 +706,12 @@ class TestCloudLoggingHandlerTask:
         filter_str = (
             'resource.type="global"\n'
             'logName="projects/project_id/logs/airflow"\n'
-            'labels.task_id="task_for_testing_cloud_logging_task_handler"\n'
-            'labels.dag_id="dag_for_testing_cloud_logging_task_handler"\n'
-            f'labels.{date_label}="2016-01-01T00:00:00+00:00"\n'
-            'labels.try_number="3"'
+            + self._expect_label_filter(
+                'labels.task_id="task_for_testing_cloud_logging_task_handler"',
+                'labels.dag_id="dag_for_testing_cloud_logging_task_handler"',
+                f'labels.{date_label}="2016-01-01T00:00:00+00:00"',
+                'labels.try_number="3"',
+            )
         )
         mock_client.return_value.list_log_entries.assert_called_once_with(
             request=ListLogEntriesRequest(
@@ -670,10 +742,12 @@ class TestCloudLoggingHandlerTask:
         filter_str = (
             'resource.type="global"\n'
             'logName="projects/project_id/logs/airflow"\n'
-            'labels.task_id="task_for_testing_cloud_logging_task_handler"\n'
-            'labels.dag_id="dag_for_testing_cloud_logging_task_handler"\n'
-            f'labels.{date_label}="2016-01-01T00:00:00+00:00"\n'
-            'labels.try_number="3"'
+            + self._expect_label_filter(
+                'labels.task_id="task_for_testing_cloud_logging_task_handler"',
+                'labels.dag_id="dag_for_testing_cloud_logging_task_handler"',
+                f'labels.{date_label}="2016-01-01T00:00:00+00:00"',
+                'labels.try_number="3"',
+            )
         )
         mock_client.return_value.list_log_entries.assert_called_once_with(
             request=ListLogEntriesRequest(
@@ -696,10 +770,12 @@ class TestCloudLoggingHandlerTask:
                 filter=(
                     'resource.type="global"\n'
                     'logName="projects/project_id/logs/airflow"\n'
-                    
'labels.task_id="task_for_testing_cloud_logging_task_handler"\n'
-                    
'labels.dag_id="dag_for_testing_cloud_logging_task_handler"\n'
-                    f'labels.{date_label}="2016-01-01T00:00:00+00:00"\n'
-                    'labels.try_number="3"'
+                    + self._expect_label_filter(
+                        
'labels.task_id="task_for_testing_cloud_logging_task_handler"',
+                        
'labels.dag_id="dag_for_testing_cloud_logging_task_handler"',
+                        f'labels.{date_label}="2016-01-01T00:00:00+00:00"',
+                        'labels.try_number="3"',
+                    )
                 ),
                 order_by="timestamp asc",
                 page_size=1000,
@@ -817,10 +893,12 @@ class TestCloudLoggingHandlerTask:
         expected_filter = [
             'resource.type="global"',
             'logName="projects/project_id/logs/airflow"',
-            f'labels.task_id="{self.ti.task_id}"',
-            f'labels.dag_id="{self.DAG_ID}"',
-            f'labels.{date_label}="{self.ti.logical_date.isoformat() if 
AIRFLOW_V_3_0_PLUS else self.ti.execution_date.isoformat()}"',
-            f'labels.try_number="{self.ti.try_number}"',
+            *self._expect_label_filter(
+                f'labels.task_id="{self.ti.task_id}"',
+                f'labels.dag_id="{self.DAG_ID}"',
+                f'labels.{date_label}="{self.ti.logical_date.isoformat() if 
AIRFLOW_V_3_0_PLUS else self.ti.execution_date.isoformat()}"',
+                f'labels.try_number="{self.ti.try_number}"',
+            ).splitlines(),
         ]
         assert set(expected_filter) == set(filter_params)
 
diff --git 
a/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py 
b/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
index 1f164cf56c8..6b61b7a6b75 100644
--- 
a/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
+++ 
b/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
@@ -46,7 +46,11 @@ from airflow.providers.common.compat.module_loading import 
import_string
 from airflow.providers.common.compat.sdk import AirflowException, 
TaskInstanceState, conf
 from airflow.providers.opensearch.log.os_json_formatter import 
OpensearchJSONFormatter
 from airflow.providers.opensearch.log.os_response import Hit, 
OpensearchResponse
-from airflow.providers.opensearch.version_compat import AIRFLOW_V_3_0_PLUS, 
AIRFLOW_V_3_2_PLUS
+from airflow.providers.opensearch.version_compat import (
+    AIRFLOW_V_3_0_PLUS,
+    AIRFLOW_V_3_2_PLUS,
+    AIRFLOW_V_3_4_PLUS,
+)
 from airflow.utils.log.file_task_handler import FileTaskHandler
 from airflow.utils.log.logging_mixin import ExternalLoggingMixin, LoggingMixin
 from airflow.utils.session import create_session
@@ -265,6 +269,31 @@ def _render_log_id(
     )
 
 
+def _get_ti_id_fields(ti: TaskInstance | RuntimeTI) -> dict[str, str]:
+    # Before 3.4 a try can reuse the previous try's id, so only log_id 
identifies it.
+    if not AIRFLOW_V_3_4_PLUS:
+        return {}
+    return {"ti_id": str(ti.id)}
+
+
+def _build_log_query(log_id: str, ti: TaskInstance | RuntimeTI) -> 
list[dict[str, Any]]:
+    log_id_match = {"match_phrase": {"log_id": log_id}}
+    # Before 3.4 a cleared task instance gets a new id, which can differ from 
the id its logs were written under.
+    if not AIRFLOW_V_3_4_PLUS:
+        return [log_id_match]
+    return [
+        {
+            "bool": {
+                "should": [
+                    {"match_phrase": {"ti_id": str(ti.id)}},
+                    {"bool": {"must": [log_id_match], "must_not": {"exists": 
{"field": "ti_id"}}}},
+                ],
+                "minimum_should_match": 1,
+            }
+        }
+    ]
+
+
 def _resolve_nested(hit: dict[Any, Any], parent_class=None) -> type[Hit]:
     """
     Resolve nested hits from OpenSearch by iteratively navigating the 
`_nested` field.
@@ -692,7 +721,7 @@ class OpensearchTaskHandler(FileTaskHandler, 
ExternalLoggingMixin, LoggingMixin)
             "query": {
                 "bool": {
                     "filter": [{"range": {self.offset_field: {"gt": 
int(offset)}}}],
-                    "must": [{"match_phrase": {"log_id": log_id}}],
+                    "must": _build_log_query(log_id, ti),
                 }
             }
         }
@@ -945,7 +974,7 @@ class OpensearchRemoteLogIO(LoggingMixin):  # noqa: D101
 
         log_id = _render_log_id(self.log_id_template, ti, ti.try_number)  # 
type: ignore[arg-type]
         if self.write_stdout or self.write_to_opensearch:
-            log_lines = self._parse_raw_log(local_loc.read_text(), log_id)
+            log_lines = self._parse_raw_log(local_loc.read_text(), log_id, 
_get_ti_id_fields(ti))  # type: ignore[arg-type]
 
             if self.write_stdout:
                 for line in log_lines:
@@ -965,7 +994,9 @@ class OpensearchRemoteLogIO(LoggingMixin):  # noqa: D101
                             parent.rmdir()
                         parent = parent.parent
 
-    def _parse_raw_log(self, log: str, log_id: str) -> list[dict[str, Any]]:
+    def _parse_raw_log(
+        self, log: str, log_id: str, extra_fields: dict[str, str] | None = None
+    ) -> list[dict[str, Any]]:
         parsed_logs = []
         offset = 1
         for line in log.split("\n"):
@@ -976,7 +1007,7 @@ class OpensearchRemoteLogIO(LoggingMixin):  # noqa: D101
             except json.JSONDecodeError:
                 self.log.warning("Skipping non-JSON log line: %r", line)
                 log_dict = {"event": line}
-            log_dict.update({"log_id": log_id, self.offset_field: offset})
+            log_dict.update({"log_id": log_id, **(extra_fields or {}), 
self.offset_field: offset})
             offset += 1
             parsed_logs.append(log_dict)
         return parsed_logs
@@ -1048,7 +1079,7 @@ class OpensearchRemoteLogIO(LoggingMixin):  # noqa: D101
             "query": {
                 "bool": {
                     "filter": [{"range": {self.offset_field: {"gt": 
int(offset)}}}],
-                    "must": [{"match_phrase": {"log_id": log_id}}],
+                    "must": _build_log_query(log_id, ti),
                 }
             }
         }
diff --git 
a/providers/opensearch/src/airflow/providers/opensearch/version_compat.py 
b/providers/opensearch/src/airflow/providers/opensearch/version_compat.py
index eb02fb81909..0909a24cc19 100644
--- a/providers/opensearch/src/airflow/providers/opensearch/version_compat.py
+++ b/providers/opensearch/src/airflow/providers/opensearch/version_compat.py
@@ -36,10 +36,12 @@ AIRFLOW_V_3_0_PLUS: bool = get_base_airflow_version_tuple() 
>= (3, 0, 0)
 AIRFLOW_V_3_1_PLUS: bool = get_base_airflow_version_tuple() >= (3, 1, 0)
 AIRFLOW_V_3_2_PLUS: bool = get_base_airflow_version_tuple() >= (3, 2, 0)
 AIRFLOW_V_3_3_PLUS: bool = get_base_airflow_version_tuple() >= (3, 3, 0)
+AIRFLOW_V_3_4_PLUS: bool = get_base_airflow_version_tuple() >= (3, 4, 0)
 
 __all__ = [
     "AIRFLOW_V_3_0_PLUS",
     "AIRFLOW_V_3_1_PLUS",
     "AIRFLOW_V_3_2_PLUS",
     "AIRFLOW_V_3_3_PLUS",
+    "AIRFLOW_V_3_4_PLUS",
 ]
diff --git 
a/providers/opensearch/tests/integration/opensearch/log/test_os_remote_log_io.py
 
b/providers/opensearch/tests/integration/opensearch/log/test_os_remote_log_io.py
index a81c3eda1c5..024ea66e1a3 100644
--- 
a/providers/opensearch/tests/integration/opensearch/log/test_os_remote_log_io.py
+++ 
b/providers/opensearch/tests/integration/opensearch/log/test_os_remote_log_io.py
@@ -40,6 +40,7 @@ class _MockTI:
     run_id: str = "integration_test_run"
     try_number: int = 1
     map_index: int = -1
+    id: uuid.UUID = dataclasses.field(default_factory=uuid.uuid4)
 
 
 @pytest.mark.integration("opensearch")
@@ -156,3 +157,34 @@ class TestOpensearchRemoteLogIOIntegration:
         log_entry = json.loads(log_messages[0])
         assert "error_detail" in log_entry
         assert log_entry["error_detail"] == error_detail
+
+    @pytest.mark.parametrize(
+        ("written", "reader", "expected"),
+        [
+            pytest.param(["legacy"], "x", ["legacy"], 
id="pre-upgrade-logs-stay-readable"),
+            pytest.param(["x", "y"], "x", ["x"], 
id="post-upgrade-reader-sees-only-its-try"),
+            pytest.param(["x", "y"], "y", ["y"], id="post-upgrade-other-try"),
+            pytest.param(["legacy", "x", "y"], "x", ["legacy", "x"], 
id="mixed-index"),
+        ],
+    )
+    @patch(
+        "airflow.providers.opensearch.log.os_task_handler.TASK_LOG_FIELDS",
+        ["message"],
+    )
+    def test_read_scopes_to_try_and_keeps_pre_upgrade_logs(self, tmp_path, 
written, reader, expected):
+        """Tries sharing one log_id stay separate, and entries without ti_id 
stay readable."""
+        try_ids = {"x": uuid.uuid4(), "y": uuid.uuid4()}
+        log_id = _render_log_id(self.opensearch_io.log_id_template, _MockTI(), 
1)
+        for name in written:
+            if name == "legacy":
+                legacy_entry = {"message": name, "log_id": log_id, "offset": 1}
+                self.opensearch_io.client.index(index=self.target_index, 
body=legacy_entry)
+                continue
+            log_file = tmp_path / f"{name}.log"
+            log_file.write_text(json.dumps({"message": name}) + "\n")
+            self.opensearch_io.upload(log_file, _MockTI(id=try_ids[name]))
+        self.opensearch_io.client.indices.refresh(index=self.target_index)
+
+        _, log_messages = self.opensearch_io.read("", 
_MockTI(id=try_ids[reader]))
+
+        assert sorted(json.loads(message)["event"] for message in 
log_messages) == sorted(expected)
diff --git 
a/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py 
b/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
index b9e6582c663..db3f80e9c4b 100644
--- a/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
+++ b/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
@@ -24,7 +24,9 @@ import os
 import re
 from io import StringIO
 from pathlib import Path
+from types import SimpleNamespace
 from unittest.mock import Mock, call, patch
+from uuid import UUID, uuid4
 
 import pendulum
 import pytest
@@ -37,7 +39,9 @@ from airflow.providers.opensearch.log.os_task_handler import (
     OpensearchRemoteLogIO,
     OpensearchTaskHandler,
     _build_log_fields,
+    _build_log_query,
     _format_error_detail,
+    _get_ti_id_fields,
     _render_log_id,
     _safe_build_structured_log_message,
     _strip_userinfo,
@@ -54,6 +58,41 @@ from tests_common.test_utils.version_compat import 
AIRFLOW_V_3_0_PLUS
 opensearchpy = pytest.importorskip("opensearchpy")
 
 
[email protected](
+    ("is_airflow_3_4_plus", "expected"),
+    [(False, {}), (True, {"ti_id": "some-ti-id"})],
+)
+def test_ti_id_is_only_written_from_airflow_3_4(is_airflow_3_4_plus, expected):
+    ti = SimpleNamespace(id="some-ti-id")
+
+    with 
patch("airflow.providers.opensearch.log.os_task_handler.AIRFLOW_V_3_4_PLUS", 
is_airflow_3_4_plus):
+        assert _get_ti_id_fields(ti) == expected
+
+
[email protected]("is_airflow_3_4_plus", [False, True])
+def test_log_query_matches_ti_id_or_documents_without_it(is_airflow_3_4_plus):
+    ti = SimpleNamespace(id=uuid4())
+    log_id_match = {"match_phrase": {"log_id": "some-log-id"}}
+
+    with 
patch("airflow.providers.opensearch.log.os_task_handler.AIRFLOW_V_3_4_PLUS", 
is_airflow_3_4_plus):
+        must = _build_log_query("some-log-id", ti)
+
+    if not is_airflow_3_4_plus:
+        assert must == [log_id_match]
+        return
+    assert must == [
+        {
+            "bool": {
+                "should": [
+                    {"match_phrase": {"ti_id": str(ti.id)}},
+                    {"bool": {"must": [log_id_match], "must_not": {"exists": 
{"field": "ti_id"}}}},
+                ],
+                "minimum_should_match": 1,
+            }
+        }
+    ]
+
+
 @dataclasses.dataclass
 class _MockTI:
     dag_id: str = "dag_for_testing_os_log_handler"
@@ -61,6 +100,7 @@ class _MockTI:
     run_id: str = "run_for_testing_os_log_handler"
     try_number: int = 1
     map_index: int = -1
+    id: UUID = dataclasses.field(default_factory=uuid4)
 
 
 def get_ti(dag_id, task_id, logical_date, create_task_instance):
@@ -729,15 +769,19 @@ class TestOpensearchRemoteLogIO:
         mock_parse.assert_not_called()
         mock_write.assert_not_called()
 
+    
@patch("airflow.providers.opensearch.log.os_task_handler.AIRFLOW_V_3_4_PLUS", 
True)
     def test_write_to_opensearch(self, tmp_json_file, ti):
         self.opensearch_io.write_stdout = False
         log_id = _render_log_id(self.opensearch_io.log_id_template, ti, 
ti.try_number)
-        expected_log_lines = 
self.opensearch_io._parse_raw_log(tmp_json_file.read_text(), log_id)
+        expected_log_lines = self.opensearch_io._parse_raw_log(
+            tmp_json_file.read_text(), log_id, {"ti_id": str(ti.id)}
+        )
 
         with patch.object(self.opensearch_io, "_write_to_opensearch", 
return_value=True) as mock_write:
             self.opensearch_io.upload(tmp_json_file, ti)
 
         mock_write.assert_called_once_with(expected_log_lines)
+        assert all(line["ti_id"] == str(ti.id) for line in expected_log_lines)
 
     def test_raw_log_contains_log_id_and_offset(self, tmp_json_file, ti):
         raw_log = tmp_json_file.read_text()
@@ -764,7 +808,7 @@ class TestOpensearchRemoteLogIO:
             "query": {
                 "bool": {
                     "filter": [{"range": {self.opensearch_io.offset_field: 
{"gt": 2}}}],
-                    "must": [{"match_phrase": {"log_id": log_id}}],
+                    "must": _build_log_query(log_id, ti),
                 }
             }
         }

Reply via email to