-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 各コンテナ内のログをポーリング #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import asyncio | ||
| import logging | ||
| from collections.abc import AsyncIterator | ||
|
|
||
| from kubernetes import client, config | ||
| from kubernetes.client.rest import ApiException | ||
|
|
@@ -459,6 +460,99 @@ def create_session_pr_job( | |
| self._create_job_idempotent(job_name, job) | ||
| return job_name | ||
|
|
||
| async def stream_pod_logs( | ||
| self, job_name: str, tail_lines: int = 100, since_seconds: int | None = None | ||
| ) -> AsyncIterator[str]: | ||
| """Stream logs from a job's pod. Yields log lines as they arrive. | ||
|
|
||
| Args: | ||
| tail_lines: Number of past log lines to include (only used for | ||
| pods that are still running and since_seconds is not set). | ||
| since_seconds: If set, only return logs newer than this many seconds. | ||
| Used to avoid replaying old logs on reconnect. | ||
| """ | ||
| max_wait = 300 # 5 minutes | ||
| poll_interval = 3 | ||
| elapsed = 0 | ||
|
|
||
| # Wait for a pod to appear | ||
| pod_name: str | None = None | ||
| sandbox_emitted = False | ||
| while elapsed < max_wait: | ||
| try: | ||
| pods = self.core_v1.list_namespaced_pod( | ||
| namespace=self.namespace, | ||
| label_selector=f"job-name={job_name}", | ||
| ) | ||
| if pods.items: | ||
| pod = pods.items[0] | ||
| pod_name = pod.metadata.name | ||
| phase = pod.status.phase if pod.status else None | ||
| if phase in ("Running", "Succeeded", "Failed"): | ||
| break | ||
| # Pod exists but not yet running | ||
| if phase == "Pending" and not sandbox_emitted: | ||
| yield '@@LOG@@{"phase":"sandbox","message":"Creating sandbox"}' | ||
| sandbox_emitted = True | ||
| except ApiException as e: | ||
| logger.warning("Error listing pods for job %s: %s", job_name, e) | ||
| await asyncio.sleep(poll_interval) | ||
| elapsed += poll_interval | ||
|
|
||
| if not pod_name: | ||
| msg = f"Pod not found for job {job_name}" | ||
| yield f'@@LOG@@{{"phase":"waiting","message":"{msg}"}}' | ||
| return | ||
|
|
||
| # Use a queue to bridge the blocking iterator and async generator | ||
| queue: asyncio.Queue[str | None] = asyncio.Queue() | ||
|
|
||
| async def _producer() -> None: | ||
| try: | ||
| loop = asyncio.get_running_loop() | ||
|
|
||
| kwargs: dict = { | ||
| "name": pod_name, | ||
| "namespace": self.namespace, | ||
| "follow": True, | ||
| "_preload_content": False, | ||
| } | ||
| if since_seconds is not None: | ||
| kwargs["since_seconds"] = since_seconds | ||
| else: | ||
| kwargs["tail_lines"] = tail_lines | ||
|
|
||
| resp = await asyncio.to_thread( | ||
| self.core_v1.read_namespaced_pod_log, | ||
| **kwargs, | ||
| ) | ||
|
|
||
| def _iter_lines() -> None: | ||
| try: | ||
| for raw_line in resp: | ||
| if isinstance(raw_line, bytes): | ||
| line = raw_line.decode("utf-8", errors="replace").rstrip("\n") | ||
| else: | ||
| line = str(raw_line).rstrip("\n") | ||
| loop.call_soon_threadsafe(queue.put_nowait, line) | ||
| finally: | ||
| loop.call_soon_threadsafe(queue.put_nowait, None) | ||
|
|
||
| await asyncio.to_thread(_iter_lines) | ||
| except Exception as e: | ||
| logger.error("Log stream producer error for %s: %s", pod_name, e) | ||
| await queue.put(None) | ||
|
|
||
| producer_task = asyncio.create_task(_producer()) | ||
| try: | ||
| while True: | ||
| line = await queue.get() | ||
| if line is None: | ||
| break | ||
| yield line | ||
| finally: | ||
| producer_task.cancel() | ||
|
|
||
|
Comment on lines
+553
to
+555
|
||
| def delete_job(self, job_name: str) -> None: | ||
| """Delete a completed job and its pods.""" | ||
| try: | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,178 @@ | ||||
| import asyncio | ||||
| import json | ||||
| import logging | ||||
| from collections.abc import AsyncIterator | ||||
| from dataclasses import dataclass, field | ||||
|
|
||||
| from app.service.k8s_service import K8sService | ||||
|
|
||||
| logger = logging.getLogger(__name__) | ||||
|
|
||||
| LOG_PREFIX = "@@LOG@@" | ||||
|
|
||||
|
|
||||
| @dataclass | ||||
| class JobInfo: | ||||
| job_name: str | ||||
| job_type: str # "analyze", "implement", "createpr" | ||||
| proposal_index: int | None = None | ||||
|
|
||||
|
|
||||
| @dataclass | ||||
| class SessionJobs: | ||||
| jobs: list[JobInfo] = field(default_factory=list) | ||||
| version: int = 0 # incremented when jobs list changes | ||||
|
|
||||
|
|
||||
| def parse_log_line(line: str) -> dict | None: | ||||
| """Parse a log line. If it has the @@LOG@@ prefix, return the parsed JSON. | ||||
| Otherwise return a generic log entry.""" | ||||
| if line.startswith(LOG_PREFIX): | ||||
| json_str = line[len(LOG_PREFIX) :] | ||||
| try: | ||||
| result: dict = json.loads(json_str) | ||||
| return result | ||||
| except json.JSONDecodeError: | ||||
| logger.debug("Failed to parse log JSON: %s", json_str[:100]) | ||||
| return None | ||||
| return None | ||||
|
|
||||
|
|
||||
| class LogStreamService: | ||||
| """Manages log streaming for sessions. Singleton per backend process.""" | ||||
|
|
||||
| def __init__(self) -> None: | ||||
| self._sessions: dict[str, SessionJobs] = {} | ||||
| self._lock = asyncio.Lock() | ||||
|
||||
| self._lock = asyncio.Lock() |
Copilot
AI
Mar 4, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LogStreamService は session/job 情報を self._sessions に保持しますが、削除処理(cleanup_session)がどこからも呼ばれていないためプロセス内メモリが増え続けます。ストリーミング完了時やセッション完了時に必ず cleanup するか、TTL で自動的に expire する仕組みを入れてください。
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
新しい SSE エンドポイント
/api/sessions/{session_id}/logs/streamに対する API テストが追加されていません。既存でbackend/tests/test_api.pyが API を広くカバーしているので、get_log_stream_serviceをスタブしてlog/doneイベントとsince_secondsの挙動を検証するテストを追加してください。