Vamsi-klu commented on code in PR #73044:
URL: https://github.com/apache/airflow/pull/73044#discussion_r4002486812


##########
providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py:
##########
@@ -1088,35 +1087,78 @@ async def read_logs(
         method is used for async output of the logs only in the pod
         failed it execution or the task was cancelled by the user.
 
+        :param name: Name of the pod.
+        :param namespace: Name of the pod's namespace.
+        :param container_name: Name of the container inside the pod.
+        :param since_seconds: Only return logs newer than a relative duration 
in seconds.
+        """
+        return [
+            line
+            async for line in self.stream_logs(
+                name=name, namespace=namespace, container_name=container_name, 
since_seconds=since_seconds
+            )
+        ]
+
+    async def stream_logs(

Review Comment:
   stream_logs never looks at raw_resp.status. With _preload_content=False, 
kubernetes_asyncio hands back the ClientResponse and does not raise on 
403/404/5xx. The error body is split on newlines, yielded as pod logs, and 
last_log_time advances. Permanent log loss, and the new retry path in the 
trigger never runs for status errors.
   
   Check raw_resp.status before iterating and raise KubernetesApiError (or the 
existing permission error) on non-2xx. The old read() path had the same hole. 
Streaming makes it worse because the body now looks like successful log lines.



##########
providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/triggers/pod.py:
##########
@@ -407,10 +411,25 @@ async def _wait_for_container_completion(self) -> 
TriggerEvent:
             now = datetime.datetime.now(tz=datetime.timezone.utc)
             if time_get_more_logs and now >= time_get_more_logs:
                 if self.get_logs and self.logging_interval:
-                    self.last_log_time = await 
self.pod_manager.fetch_container_logs_before_current_sec(
-                        pod, container_name=self.base_container_name, 
since_time=self.last_log_time
-                    )
+                    # Advance before fetching so a failed read waits a full 
interval rather than
+                    # being retried on the next poll.
                     time_get_more_logs = now + 
datetime.timedelta(seconds=self.logging_interval)
+                    try:
+                        self.last_log_time = await 
self.pod_manager.fetch_container_logs_before_current_sec(
+                            pod,
+                            container_name=self.base_container_name,
+                            since_time=self.last_log_time,
+                        )
+                    except (ClientError, asyncio.TimeoutError, 
KubernetesApiError) as e:

Review Comment:
   This is the behavior change the PR sells: swallow the read, wait a full 
logging_interval, do not re-defer. There is no test.
   
   Parameterize ClientError, TimeoutError, and KubernetesApiError. Assert 
last_log_time is unchanged, the warning fires, and the next fetch waits 
logging_interval.



##########
providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/hooks/kubernetes.py:
##########
@@ -1088,35 +1087,78 @@ async def read_logs(
         method is used for async output of the logs only in the pod
         failed it execution or the task was cancelled by the user.
 
+        :param name: Name of the pod.
+        :param namespace: Name of the pod's namespace.
+        :param container_name: Name of the container inside the pod.
+        :param since_seconds: Only return logs newer than a relative duration 
in seconds.
+        """
+        return [
+            line
+            async for line in self.stream_logs(
+                name=name, namespace=namespace, container_name=container_name, 
since_seconds=since_seconds
+            )
+        ]
+
+    async def stream_logs(
+        self, name: str, namespace: str, container_name: str | None = None, 
since_seconds: int | None = None
+    ) -> AsyncGenerator[str, None]:
+        """
+        Yield pod log lines one at a time, without buffering the whole window.
+
         :param name: Name of the pod.
         :param namespace: Name of the pod's namespace.
         :param container_name: Name of the container inside the pod.
         :param since_seconds: Only return logs newer than a relative duration 
in seconds.
         """
         async with self.get_conn() as connection:
-            try:
-                v1_api = async_client.CoreV1Api(connection)
-                # Always retrieve raw bytes and decode with 'replace' to avoid
-                # UnicodeDecodeError when pod output contains non-UTF-8 bytes
-                # (e.g. binary data, truncated multi-byte sequences).
-                # kubernetes_asyncio's default decoding uses strict UTF-8 which
-                # crashes the task in those cases.
-                kwargs: dict[str, Any] = {
-                    "name": name,
-                    "namespace": namespace,
-                    "follow": False,
-                    "timestamps": True,
-                    "_preload_content": False,
-                }
-                if container_name is not None:
-                    kwargs["container"] = container_name
-                if since_seconds is not None:
-                    kwargs["since_seconds"] = since_seconds
+            v1_api = async_client.CoreV1Api(connection)
+            kwargs: dict[str, Any] = {
+                "name": name,
+                "namespace": namespace,
+                "follow": False,
+                "timestamps": True,
+                "_preload_content": False,
+            }
+            if container_name is not None:
+                kwargs["container"] = container_name
+            if since_seconds is not None:
+                kwargs["since_seconds"] = since_seconds
 
+            try:
                 raw_resp: ClientResponse = await 
v1_api.read_namespaced_pod_log(**kwargs)  # type: ignore  # 
_preload_content=False makes returning ClientResponse instead of str!
-                raw_bytes = await raw_resp.read()
-                # CPU-bound decode/split, offloaded so it can't block the 
triggerer event loop.
-                return await asyncio.to_thread(_split_log_bytes, raw_bytes)
+                try:
+                    pending: list[bytes] = []
+                    lines_since_yield = 0
+                    # Not ``async for line in raw_resp.content``: 
``readline()`` raises
+                    # ``LineTooLong`` past the 4 MiB high-water mark.
+                    # Decoding below uses 'replace' because kubernetes_asyncio 
decodes with strict
+                    # UTF-8, which raises UnicodeDecodeError and crashes the 
task when pod output
+                    # contains binary data or truncated multi-byte sequences.
+                    async for chunk in 
raw_resp.content.iter_chunked(_LOG_STREAM_CHUNK_SIZE):
+                        if b"\n" in chunk:
+                            *lines, tail = chunk.split(b"\n")
+                            if pending:
+                                pending.append(lines[0])
+                                lines[0] = b"".join(pending)
+                                pending.clear()
+                            for line in lines:
+                                # Safe per line: ``\n`` never appears inside a 
multi-byte sequence.
+                                yield line.removesuffix(b"\r").decode("utf-8", 
errors="replace")
+                                lines_since_yield += 1
+                                if lines_since_yield >= 
_LOG_STREAM_YIELD_EVERY_LINES:
+                                    lines_since_yield = 0
+                                    await asyncio.sleep(0)
+                            if tail:
+                                pending.append(tail)
+                        else:
+                            # Appended whole: concatenating per chunk would 
recopy the line so far
+                            # every time, which is quadratic when a container 
emits no newline.
+                            pending.append(chunk)
+                        await asyncio.sleep(0)
+                    if pending:
+                        yield 
b"".join(pending).removesuffix(b"\r").decode("utf-8", errors="replace")
+                finally:
+                    raw_resp.close()
             except HTTPError as e:

Review Comment:
   except HTTPError is urllib3.exceptions.HTTPError. This is the async client. 
Failures here are aiohttp / ApiException, so this handler is dead for the new 
path unless something still raises urllib3 through this stack. Catch what the 
async client actually raises, or delete it.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to