From b7deeca3dbd0b71c8639d4ef0d7d4679e8a09959 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 21:24:31 +0900 Subject: [PATCH 01/36] chore: ignore local .worktrees/ for parallel feature development --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5343ae9a..656b2e2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .vscode -.claude \ No newline at end of file +.claude + +# Local worktrees for parallel feature development +.worktrees/ \ No newline at end of file From 21b2d4e0c84f8822250e9e0698c8e1b9f0510fab Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 21:29:14 +0900 Subject: [PATCH 02/36] =?UTF-8?q?feat(infra):=20stackup.realtime=20exchang?= =?UTF-8?q?e=20=EB=B0=8F=20session.notify=20=ED=81=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/rabbitmq/definitions.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/infra/rabbitmq/definitions.json b/infra/rabbitmq/definitions.json index db8ac9a0..1986b44a 100644 --- a/infra/rabbitmq/definitions.json +++ b/infra/rabbitmq/definitions.json @@ -16,6 +16,13 @@ "type": "topic", "durable": true, "auto_delete": false + }, + { + "name": "stackup.realtime", + "vhost": "/", + "type": "topic", + "durable": true, + "auto_delete": false } ], "queues": [ @@ -60,6 +67,13 @@ "durable": true, "auto_delete": false, "arguments": {} + }, + { + "name": "q.realtime.session.notify", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} } ], "bindings": [ @@ -104,6 +118,13 @@ "destination": "core.callback.questions", "destination_type": "queue", "routing_key": "callback.questions" + }, + { + "source": "stackup.realtime", + "vhost": "/", + "destination": "q.realtime.session.notify", + "destination_type": "queue", + "routing_key": "realtime.session.*" } ] } From ddd7507ceea9b46099f9c0d284982aa00b558efa Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 21:34:03 +0900 Subject: [PATCH 03/36] =?UTF-8?q?feat(ai):=20=EB=A9=94=EC=8B=9C=EC=A7=95?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/src/ai_server/config/settings.py | 8 ++++++++ ai/tests/test_settings.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 ai/tests/test_settings.py diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 92f5a571..0a8530af 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -16,6 +16,14 @@ class Settings(BaseSettings): # RabbitMQ rabbitmq_url: str = "amqp://stackup:stackup@localhost:5672/" + # AI consumer + ai_queue_resume: str = "ai.analyze.resume" + ai_queue_prefetch: int = 10 + ai_callback_exchange: str = "stackup.ai-to-core" + ai_callback_routing_analysis: str = "callback.analysis" + ai_publisher_name: str = "ai-server" + ai_idempotency_lru_size: int = 1024 + # S3 / MinIO s3_endpoint_url: str = "http://localhost:9000" s3_access_key: str = "" diff --git a/ai/tests/test_settings.py b/ai/tests/test_settings.py new file mode 100644 index 00000000..98818e43 --- /dev/null +++ b/ai/tests/test_settings.py @@ -0,0 +1,29 @@ +import os + +import pytest + +from ai_server.config.settings import Settings + + +def test_settings_defaults_for_messaging(monkeypatch: pytest.MonkeyPatch) -> None: + # __init__.py가 .env를 우연히 읽지 않도록 환경 격리 + for key in [ + "RABBITMQ_URL", + "AI_QUEUE_RESUME", + "AI_QUEUE_PREFETCH", + "AI_CALLBACK_EXCHANGE", + "AI_CALLBACK_ROUTING_ANALYSIS", + "AI_PUBLISHER_NAME", + "AI_IDEMPOTENCY_LRU_SIZE", + ]: + monkeypatch.delenv(key, raising=False) + + s = Settings(_env_file=None) # type: ignore[call-arg] + + assert s.rabbitmq_url == "amqp://stackup:stackup@localhost:5672/" + assert s.ai_queue_resume == "ai.analyze.resume" + assert s.ai_queue_prefetch == 10 + assert s.ai_callback_exchange == "stackup.ai-to-core" + assert s.ai_callback_routing_analysis == "callback.analysis" + assert s.ai_publisher_name == "ai-server" + assert s.ai_idempotency_lru_size == 1024 From 92684bd88bd606450a40a31ce46dd5b7a120e9ab Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 21:40:37 +0900 Subject: [PATCH 04/36] =?UTF-8?q?feat(ai):=20envelope=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20Pydantic=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/src/ai_server/model/__init__.py | 0 ai/src/ai_server/model/envelope.py | 35 ++++++++++++++++ ai/tests/test_envelope.py | 67 ++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 ai/src/ai_server/model/__init__.py create mode 100644 ai/src/ai_server/model/envelope.py create mode 100644 ai/tests/test_envelope.py diff --git a/ai/src/ai_server/model/__init__.py b/ai/src/ai_server/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai/src/ai_server/model/envelope.py b/ai/src/ai_server/model/envelope.py new file mode 100644 index 00000000..b93e1e3c --- /dev/null +++ b/ai/src/ai_server/model/envelope.py @@ -0,0 +1,35 @@ +from datetime import datetime +from typing import Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +PayloadT = TypeVar("PayloadT", bound=BaseModel) + + +def _camel_config() -> ConfigDict: + return ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", + ) + + +class MessageContext(BaseModel): + model_config = _camel_config() + + user_id: int | None = None + session_id: int | None = None + + +class Envelope(BaseModel, Generic[PayloadT]): + model_config = _camel_config() + + message_id: str + message_type: str + version: str = "v1" + trace_id: str + published_at: datetime + publisher: str + payload: PayloadT + context: MessageContext = Field(default_factory=MessageContext) diff --git a/ai/tests/test_envelope.py b/ai/tests/test_envelope.py new file mode 100644 index 00000000..69c7ae7b --- /dev/null +++ b/ai/tests/test_envelope.py @@ -0,0 +1,67 @@ +import json +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +from pydantic import BaseModel, ValidationError + +from ai_server.model.envelope import Envelope, MessageContext + + +class _Payload(BaseModel): + foo: int + + +def _sample(payload: dict, **overrides: object) -> dict: + base = { + "messageId": str(uuid4()), + "messageType": "analyze.resume", + "version": "v1", + "traceId": "trace-123", + "publishedAt": "2026-05-08T00:00:00Z", + "publisher": "core-server", + "payload": payload, + "context": {"userId": 1}, + } + base.update(overrides) + return base + + +def test_envelope_parses_minimum_fields() -> None: + raw = _sample({"foo": 7}) + env = Envelope[_Payload].model_validate(raw) + assert env.message_type == "analyze.resume" + assert env.payload.foo == 7 + assert env.context.user_id == 1 + assert env.context.session_id is None + + +def test_envelope_ignores_unknown_fields() -> None: + raw = _sample({"foo": 1, "newField": "ok"}, extraField="ignored") + raw["context"]["futureKey"] = "ignored" + env = Envelope[_Payload].model_validate(raw) + assert env.payload.foo == 1 + + +def test_envelope_rejects_missing_required_field() -> None: + raw = _sample({"foo": 1}) + del raw["traceId"] + with pytest.raises(ValidationError): + Envelope[_Payload].model_validate(raw) + + +def test_envelope_serializes_camel_case() -> None: + raw = _sample({"foo": 1}) + env = Envelope[_Payload].model_validate(raw) + dumped = json.loads(env.model_dump_json(by_alias=True)) + assert "messageId" in dumped + assert "messageType" in dumped + assert "traceId" in dumped + assert "publishedAt" in dumped + assert dumped["context"]["userId"] == 1 + + +def test_message_context_session_id_optional() -> None: + ctx = MessageContext.model_validate({"userId": 5, "sessionId": 42}) + assert ctx.user_id == 5 + assert ctx.session_id == 42 From 5bdbe0cb3537b41d8e7b36ef89a249b346498ff9 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 21:46:15 +0900 Subject: [PATCH 05/36] =?UTF-8?q?feat(ai):=20=EC=9D=B4=EB=A0=A5=EC=84=9C?= =?UTF-8?q?=20=EB=B6=84=EC=84=9D=20=EC=9A=94=EC=B2=AD/=EC=BD=9C=EB=B0=B1?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/src/ai_server/model/messages/__init__.py | 0 ai/src/ai_server/model/messages/analyze.py | 37 ++++++++++++++++ ai/tests/test_messages_analyze.py | 48 +++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 ai/src/ai_server/model/messages/__init__.py create mode 100644 ai/src/ai_server/model/messages/analyze.py create mode 100644 ai/tests/test_messages_analyze.py diff --git a/ai/src/ai_server/model/messages/__init__.py b/ai/src/ai_server/model/messages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai/src/ai_server/model/messages/analyze.py b/ai/src/ai_server/model/messages/analyze.py new file mode 100644 index 00000000..a2ddb66e --- /dev/null +++ b/ai/src/ai_server/model/messages/analyze.py @@ -0,0 +1,37 @@ +from typing import Literal + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +TargetType = Literal["RESUME", "REPOSITORY"] +AnalysisStatus = Literal["ANALYZED", "FAILED"] + + +def _camel_config() -> ConfigDict: + return ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", + ) + + +class ResumeAnalyzeRequest(BaseModel): + model_config = _camel_config() + + resume_id: int + s3_key: str + + +class AnalysisCallbackPayload(BaseModel): + model_config = _camel_config() + + target_type: TargetType + target_id: int + status: AnalysisStatus + summary: str | None = None + tech_stack: list[str] = [] + document_s3_key: str | None = None + embedding_chunk_count: int = 0 + error_code: str | None = None + error_message: str | None = None + retriable: bool | None = None diff --git a/ai/tests/test_messages_analyze.py b/ai/tests/test_messages_analyze.py new file mode 100644 index 00000000..49a6d3ab --- /dev/null +++ b/ai/tests/test_messages_analyze.py @@ -0,0 +1,48 @@ +import pytest +from pydantic import ValidationError + +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + ResumeAnalyzeRequest, +) + + +def test_resume_request_parses() -> None: + req = ResumeAnalyzeRequest.model_validate( + {"resumeId": 42, "s3Key": "resumes/raw/123/abc.pdf"} + ) + assert req.resume_id == 42 + assert req.s3_key == "resumes/raw/123/abc.pdf" + + +def test_resume_request_requires_fields() -> None: + with pytest.raises(ValidationError): + ResumeAnalyzeRequest.model_validate({"resumeId": 42}) + + +def test_callback_success_serializes_camel_case() -> None: + cb = AnalysisCallbackPayload( + target_type="RESUME", + target_id=42, + status="ANALYZED", + summary="[ECHO] not yet analyzed", + tech_stack=[], + document_s3_key="echo/resume/42/echo.md", + embedding_chunk_count=0, + ) + dumped = cb.model_dump(by_alias=True) + assert dumped["targetType"] == "RESUME" + assert dumped["targetId"] == 42 + assert dumped["status"] == "ANALYZED" + assert dumped["techStack"] == [] + assert dumped["documentS3Key"] == "echo/resume/42/echo.md" + assert dumped["embeddingChunkCount"] == 0 + + +def test_callback_target_type_must_be_known() -> None: + with pytest.raises(ValidationError): + AnalysisCallbackPayload( + target_type="UNKNOWN", # type: ignore[arg-type] + target_id=1, + status="ANALYZED", + ) From 6d141c8fc76207ef20869eecd20950252f260fd4 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 21:52:16 +0900 Subject: [PATCH 06/36] =?UTF-8?q?refactor(ai):=20=EA=B3=B5=EC=9A=A9=20came?= =?UTF-8?q?l=5Fconfig=20=ED=97=AC=ED=8D=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/src/ai_server/model/_config.py | 16 ++++++++++++++++ ai/src/ai_server/model/envelope.py | 17 +++++------------ ai/src/ai_server/model/messages/analyze.py | 17 +++++------------ 3 files changed, 26 insertions(+), 24 deletions(-) create mode 100644 ai/src/ai_server/model/_config.py diff --git a/ai/src/ai_server/model/_config.py b/ai/src/ai_server/model/_config.py new file mode 100644 index 00000000..618d00a3 --- /dev/null +++ b/ai/src/ai_server/model/_config.py @@ -0,0 +1,16 @@ +from pydantic import ConfigDict +from pydantic.alias_generators import to_camel + + +def camel_config() -> ConfigDict: + """Shared Pydantic ConfigDict for camelCase JSON ↔ snake_case Python. + + Used by all message envelope and payload models in `ai_server.model`. + `populate_by_name=True` allows constructing instances with snake_case kwargs. + `extra="ignore"` provides forward compatibility with new wire fields. + """ + return ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", + ) diff --git a/ai/src/ai_server/model/envelope.py b/ai/src/ai_server/model/envelope.py index b93e1e3c..7a5af61c 100644 --- a/ai/src/ai_server/model/envelope.py +++ b/ai/src/ai_server/model/envelope.py @@ -1,29 +1,22 @@ from datetime import datetime from typing import Generic, TypeVar -from pydantic import BaseModel, ConfigDict, Field -from pydantic.alias_generators import to_camel - -PayloadT = TypeVar("PayloadT", bound=BaseModel) +from pydantic import BaseModel, Field +from ai_server.model._config import camel_config -def _camel_config() -> ConfigDict: - return ConfigDict( - alias_generator=to_camel, - populate_by_name=True, - extra="ignore", - ) +PayloadT = TypeVar("PayloadT", bound=BaseModel) class MessageContext(BaseModel): - model_config = _camel_config() + model_config = camel_config() user_id: int | None = None session_id: int | None = None class Envelope(BaseModel, Generic[PayloadT]): - model_config = _camel_config() + model_config = camel_config() message_id: str message_type: str diff --git a/ai/src/ai_server/model/messages/analyze.py b/ai/src/ai_server/model/messages/analyze.py index a2ddb66e..a9a5f473 100644 --- a/ai/src/ai_server/model/messages/analyze.py +++ b/ai/src/ai_server/model/messages/analyze.py @@ -1,29 +1,22 @@ from typing import Literal -from pydantic import BaseModel, ConfigDict -from pydantic.alias_generators import to_camel +from pydantic import BaseModel + +from ai_server.model._config import camel_config TargetType = Literal["RESUME", "REPOSITORY"] AnalysisStatus = Literal["ANALYZED", "FAILED"] -def _camel_config() -> ConfigDict: - return ConfigDict( - alias_generator=to_camel, - populate_by_name=True, - extra="ignore", - ) - - class ResumeAnalyzeRequest(BaseModel): - model_config = _camel_config() + model_config = camel_config() resume_id: int s3_key: str class AnalysisCallbackPayload(BaseModel): - model_config = _camel_config() + model_config = camel_config() target_type: TargetType target_id: int From 3add40018b4ef066476288295498cc53cb06e97f Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 21:56:14 +0900 Subject: [PATCH 07/36] =?UTF-8?q?feat(ai):=20messageId=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=EC=9A=A9=20LRU=20=EB=A9=B1?= =?UTF-8?q?=EB=93=B1=20=EC=8A=A4=ED=86=A0=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/src/ai_server/messaging/__init__.py | 0 ai/src/ai_server/messaging/idempotency.py | 27 +++++++++++++++++++ ai/tests/test_idempotency.py | 33 +++++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 ai/src/ai_server/messaging/__init__.py create mode 100644 ai/src/ai_server/messaging/idempotency.py create mode 100644 ai/tests/test_idempotency.py diff --git a/ai/src/ai_server/messaging/__init__.py b/ai/src/ai_server/messaging/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai/src/ai_server/messaging/idempotency.py b/ai/src/ai_server/messaging/idempotency.py new file mode 100644 index 00000000..a7308dcc --- /dev/null +++ b/ai/src/ai_server/messaging/idempotency.py @@ -0,0 +1,27 @@ +from collections import OrderedDict +from threading import Lock + + +class LruIdempotencyStore: + """Thread-safe LRU set for messageId-based idempotency. + + `is_seen_then_mark(key)` returns True if the key was already present + (caller should ACK + skip), or False if it's new (record it and process). + """ + + def __init__(self, max_size: int) -> None: + if max_size <= 0: + raise ValueError("max_size must be > 0") + self._max_size = max_size + self._store: OrderedDict[str, None] = OrderedDict() + self._lock = Lock() + + def is_seen_then_mark(self, key: str) -> bool: + with self._lock: + if key in self._store: + self._store.move_to_end(key) + return True + self._store[key] = None + if len(self._store) > self._max_size: + self._store.popitem(last=False) + return False diff --git a/ai/tests/test_idempotency.py b/ai/tests/test_idempotency.py new file mode 100644 index 00000000..7b5b9936 --- /dev/null +++ b/ai/tests/test_idempotency.py @@ -0,0 +1,33 @@ +from ai_server.messaging.idempotency import LruIdempotencyStore + + +def test_first_seen_returns_false() -> None: + store = LruIdempotencyStore(max_size=4) + assert store.is_seen_then_mark("a") is False + + +def test_second_seen_returns_true() -> None: + store = LruIdempotencyStore(max_size=4) + store.is_seen_then_mark("a") + assert store.is_seen_then_mark("a") is True + + +def test_evicts_oldest_when_full() -> None: + store = LruIdempotencyStore(max_size=2) + store.is_seen_then_mark("a") + store.is_seen_then_mark("b") + store.is_seen_then_mark("c") # evicts "a" + assert store.is_seen_then_mark("b") is True + assert store.is_seen_then_mark("a") is False + + +def test_recently_used_avoids_eviction() -> None: + store = LruIdempotencyStore(max_size=2) + store.is_seen_then_mark("a") + store.is_seen_then_mark("b") + # Touch "a" to make it most recent + assert store.is_seen_then_mark("a") is True + # Now adding "c" should evict "b" instead of "a" + store.is_seen_then_mark("c") + assert store.is_seen_then_mark("a") is True + assert store.is_seen_then_mark("b") is False From 6f279e2caa2720e2d984b0edd293562c751404a2 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:02:14 +0900 Subject: [PATCH 08/36] =?UTF-8?q?feat(ai):=20aio-pika=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EB=9E=98=ED=8D=BC=20=EB=B0=8F=20=EC=BD=9C=EB=B0=B1=20?= =?UTF-8?q?=ED=8D=BC=EB=B8=94=EB=A6=AC=EC=85=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/src/ai_server/messaging/connection.py | 49 ++++++++++++++ ai/src/ai_server/messaging/publisher.py | 81 ++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 ai/src/ai_server/messaging/connection.py create mode 100644 ai/src/ai_server/messaging/publisher.py diff --git a/ai/src/ai_server/messaging/connection.py b/ai/src/ai_server/messaging/connection.py new file mode 100644 index 00000000..c170d5fc --- /dev/null +++ b/ai/src/ai_server/messaging/connection.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import structlog +from aio_pika import connect_robust +from aio_pika.abc import AbstractRobustChannel, AbstractRobustConnection + +log = structlog.get_logger(__name__) + + +class RabbitConnection: + """Thin wrapper around aio-pika's connect_robust. + + Holds one connection + one channel. Auto-reconnect is provided by + aio-pika; we just expose lifecycle hooks for FastAPI lifespan. + """ + + def __init__(self, url: str, prefetch: int) -> None: + self._url = url + self._prefetch = prefetch + self._connection: AbstractRobustConnection | None = None + self._channel: AbstractRobustChannel | None = None + + async def open(self) -> None: + log.info("rabbit.connect.start", url_masked=_mask(self._url)) + self._connection = await connect_robust(self._url) + channel = await self._connection.channel() + await channel.set_qos(prefetch_count=self._prefetch) + self._channel = channel # type: ignore[assignment] + log.info("rabbit.connect.ok", prefetch=self._prefetch) + + async def close(self) -> None: + if self._connection is not None and not self._connection.is_closed: + await self._connection.close() + log.info("rabbit.connect.closed") + + @property + def channel(self) -> AbstractRobustChannel: + if self._channel is None: + raise RuntimeError("RabbitConnection not opened") + return self._channel + + +def _mask(url: str) -> str: + # amqp://user:pass@host:port/ → amqp://***@host:port/ + if "@" not in url: + return url + scheme, rest = url.split("://", 1) if "://" in url else ("", url) + creds, hostpart = rest.split("@", 1) + return f"{scheme}://***@{hostpart}" if scheme else f"***@{hostpart}" diff --git a/ai/src/ai_server/messaging/publisher.py b/ai/src/ai_server/messaging/publisher.py new file mode 100644 index 00000000..2f0b7819 --- /dev/null +++ b/ai/src/ai_server/messaging/publisher.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import uuid4 + +import structlog +from aio_pika import DeliveryMode, ExchangeType, Message +from pydantic import BaseModel + +from ai_server.messaging.connection import RabbitConnection +from ai_server.model.envelope import Envelope, MessageContext + +log = structlog.get_logger(__name__) + + +class CallbackPublisher: + def __init__( + self, + connection: RabbitConnection, + exchange_name: str, + publisher_name: str, + ) -> None: + self._connection = connection + self._exchange_name = exchange_name + self._publisher_name = publisher_name + self._exchange = None # set in open() + + async def open(self) -> None: + self._exchange = await self._connection.channel.declare_exchange( + self._exchange_name, + ExchangeType.TOPIC, + durable=True, + passive=True, # 정의 파일이 이미 만들었음. 우리는 잡기만. + ) + + async def publish( + self, + *, + routing_key: str, + message_type: str, + payload: BaseModel, + trace_id: str, + correlation_id: str, + context: MessageContext, + version: str = "v1", + ) -> None: + if self._exchange is None: + raise RuntimeError("CallbackPublisher not opened") + + envelope = Envelope[type(payload)].model_validate( # type: ignore[misc] + { + "messageId": str(uuid4()), + "messageType": message_type, + "version": version, + "traceId": trace_id, + "publishedAt": datetime.now(timezone.utc), + "publisher": self._publisher_name, + "payload": payload.model_dump(by_alias=True), + "context": context.model_dump(by_alias=True), + } + ) + + body = envelope.model_dump_json(by_alias=True).encode("utf-8") + msg = Message( + body=body, + content_type="application/json", + content_encoding="utf-8", + delivery_mode=DeliveryMode.PERSISTENT, + message_id=envelope.message_id, + correlation_id=correlation_id, + headers={"x-trace-id": trace_id, "x-attempt": 0}, + ) + await self._exchange.publish(msg, routing_key=routing_key) + log.info( + "rabbit.publish", + exchange=self._exchange_name, + routing_key=routing_key, + message_id=envelope.message_id, + correlation_id=correlation_id, + trace_id=trace_id, + ) From a1beff78d193c944e4a8004e13d7edaee17f5a12 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:07:43 +0900 Subject: [PATCH 09/36] =?UTF-8?q?feat(ai):=20=EB=A9=B1=EB=93=B1=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=ED=8F=AC=ED=95=A8=20resume=20echo=20?= =?UTF-8?q?=EC=BB=A8=EC=8A=88=EB=A8=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai_server/messaging/consumers/__init__.py | 0 .../messaging/consumers/resume_consumer.py | 86 +++++++++++++++++++ ai/tests/test_resume_consumer.py | 56 ++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 ai/src/ai_server/messaging/consumers/__init__.py create mode 100644 ai/src/ai_server/messaging/consumers/resume_consumer.py create mode 100644 ai/tests/test_resume_consumer.py diff --git a/ai/src/ai_server/messaging/consumers/__init__.py b/ai/src/ai_server/messaging/consumers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai/src/ai_server/messaging/consumers/resume_consumer.py b/ai/src/ai_server/messaging/consumers/resume_consumer.py new file mode 100644 index 00000000..6142359e --- /dev/null +++ b/ai/src/ai_server/messaging/consumers/resume_consumer.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import structlog +from aio_pika.abc import AbstractIncomingMessage + +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.messaging.publisher import CallbackPublisher +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + ResumeAnalyzeRequest, +) + +log = structlog.get_logger(__name__) + + +def build_echo_callback( + envelope: Envelope[ResumeAnalyzeRequest], +) -> AnalysisCallbackPayload: + rid = envelope.payload.resume_id + return AnalysisCallbackPayload( + target_type="RESUME", + target_id=rid, + status="ANALYZED", + summary="[ECHO] not yet analyzed", + tech_stack=[], + document_s3_key=f"echo/resume/{rid}/echo.md", + embedding_chunk_count=0, + ) + + +class ResumeConsumer: + def __init__( + self, + *, + publisher: CallbackPublisher, + idempotency: LruIdempotencyStore, + callback_routing_key: str, + ) -> None: + self._publisher = publisher + self._idempotency = idempotency + self._callback_routing_key = callback_routing_key + + async def handle(self, message: AbstractIncomingMessage) -> None: + async with message.process(requeue=False): + try: + envelope = Envelope[ResumeAnalyzeRequest].model_validate_json( + message.body + ) + except Exception as exc: # parse error → DLQ-ready (auto NACK on raise) + log.error( + "resume.parse.failed", + error=str(exc), + delivery_tag=message.delivery_tag, + ) + raise + + if self._idempotency.is_seen_then_mark(envelope.message_id): + log.info( + "resume.idempotent.skip", + message_id=envelope.message_id, + trace_id=envelope.trace_id, + ) + return + + log.info( + "resume.echo.start", + message_id=envelope.message_id, + resume_id=envelope.payload.resume_id, + trace_id=envelope.trace_id, + ) + callback = build_echo_callback(envelope) + await self._publisher.publish( + routing_key=self._callback_routing_key, + message_type="callback.analysis", + payload=callback, + trace_id=envelope.trace_id, + correlation_id=envelope.message_id, + context=envelope.context, + ) + log.info( + "resume.echo.done", + message_id=envelope.message_id, + resume_id=envelope.payload.resume_id, + trace_id=envelope.trace_id, + ) diff --git a/ai/tests/test_resume_consumer.py b/ai/tests/test_resume_consumer.py new file mode 100644 index 00000000..ccf410da --- /dev/null +++ b/ai/tests/test_resume_consumer.py @@ -0,0 +1,56 @@ +from datetime import datetime, timezone + +from ai_server.messaging.consumers.resume_consumer import ( + build_echo_callback, +) +from ai_server.model.envelope import Envelope, MessageContext +from ai_server.model.messages.analyze import ResumeAnalyzeRequest + + +def _request_envelope() -> Envelope[ResumeAnalyzeRequest]: + return Envelope[ResumeAnalyzeRequest].model_validate( + { + "messageId": "req-1", + "messageType": "analyze.resume", + "version": "v1", + "traceId": "trace-x", + "publishedAt": datetime(2026, 5, 8, tzinfo=timezone.utc), + "publisher": "core-server", + "payload": {"resumeId": 42, "s3Key": "resumes/raw/123/abc.pdf"}, + "context": {"userId": 123}, + } + ) + + +def test_echo_callback_target_type_resume() -> None: + cb = build_echo_callback(_request_envelope()) + assert cb.target_type == "RESUME" + assert cb.target_id == 42 + assert cb.status == "ANALYZED" + + +def test_echo_callback_includes_marker_summary() -> None: + cb = build_echo_callback(_request_envelope()) + assert cb.summary is not None + assert "ECHO" in cb.summary + assert cb.tech_stack == [] + assert cb.document_s3_key == "echo/resume/42/echo.md" + assert cb.embedding_chunk_count == 0 + + +def test_echo_callback_preserves_resume_id_in_s3_key() -> None: + env = _request_envelope() + env_dict = env.model_dump(by_alias=True) + env_dict["payload"]["resumeId"] = 7 + other = Envelope[ResumeAnalyzeRequest].model_validate(env_dict) + cb = build_echo_callback(other) + assert cb.target_id == 7 + assert cb.document_s3_key == "echo/resume/7/echo.md" + + +def test_extract_context_passes_user_id() -> None: + env = _request_envelope() + cb = build_echo_callback(env) + # context 검증은 consumer entry에서 하지만 핸들러에 의존하지 않음. + # 여기서는 envelope.context가 그대로 보존되는지만 확인. + assert env.context.user_id == 123 From e43dd291476f7a1b142bd3309a7a681ac405c9a0 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:12:17 +0900 Subject: [PATCH 10/36] =?UTF-8?q?feat(ai):=20FastAPI=20lifespan=EC=97=90?= =?UTF-8?q?=20=EB=A9=94=EC=8B=9C=EC=A7=95=20=EB=9F=B0=ED=83=80=EC=9E=84=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/src/ai_server/main.py | 23 ++++++++--- ai/src/ai_server/messaging/runner.py | 62 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 ai/src/ai_server/messaging/runner.py diff --git a/ai/src/ai_server/main.py b/ai/src/ai_server/main.py index 59fbd0f9..49673a52 100644 --- a/ai/src/ai_server/main.py +++ b/ai/src/ai_server/main.py @@ -1,17 +1,28 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +import structlog from fastapi import FastAPI from ai_server.api.health import router as health_router from ai_server.config.settings import Settings, get_settings +from ai_server.messaging.runner import MessagingRuntime +log = structlog.get_logger(__name__) -@asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: - # Startup - yield - # Shutdown + +def _build_lifespan(settings: Settings): + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + runtime = MessagingRuntime(settings) + await runtime.start() + app.state.messaging = runtime + try: + yield + finally: + await runtime.stop() + + return lifespan def create_app(settings: Settings | None = None) -> FastAPI: @@ -21,7 +32,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: app = FastAPI( title=settings.app_name, version=settings.app_version, - lifespan=lifespan, + lifespan=_build_lifespan(settings), ) app.include_router(health_router) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py new file mode 100644 index 00000000..fdded71b --- /dev/null +++ b/ai/src/ai_server/messaging/runner.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import structlog +from aio_pika.abc import AbstractRobustQueue + +from ai_server.config.settings import Settings +from ai_server.messaging.connection import RabbitConnection +from ai_server.messaging.consumers.resume_consumer import ResumeConsumer +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.messaging.publisher import CallbackPublisher + +log = structlog.get_logger(__name__) + + +class MessagingRuntime: + """Holds long-lived messaging components for FastAPI lifespan.""" + + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._connection = RabbitConnection( + url=settings.rabbitmq_url, + prefetch=settings.ai_queue_prefetch, + ) + self._publisher = CallbackPublisher( + connection=self._connection, + exchange_name=settings.ai_callback_exchange, + publisher_name=settings.ai_publisher_name, + ) + self._idempotency = LruIdempotencyStore( + max_size=settings.ai_idempotency_lru_size, + ) + self._resume_consumer = ResumeConsumer( + publisher=self._publisher, + idempotency=self._idempotency, + callback_routing_key=settings.ai_callback_routing_analysis, + ) + self._resume_queue: AbstractRobustQueue | None = None + self._consumer_tag: str | None = None + + async def start(self) -> None: + await self._connection.open() + await self._publisher.open() + + channel = self._connection.channel + queue = await channel.declare_queue( + self._settings.ai_queue_resume, + durable=True, + passive=True, # 정의 파일이 이미 선언함 + ) + self._resume_queue = queue + self._consumer_tag = await queue.consume(self._resume_consumer.handle) + log.info( + "ai.consumer.started", + queue=self._settings.ai_queue_resume, + consumer_tag=self._consumer_tag, + ) + + async def stop(self) -> None: + if self._resume_queue is not None and self._consumer_tag is not None: + await self._resume_queue.cancel(self._consumer_tag) + log.info("ai.consumer.stopped", consumer_tag=self._consumer_tag) + await self._connection.close() From 9d347899cf1e15ad4e16dd7346d81d4cefceba09 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:21:30 +0900 Subject: [PATCH 11/36] =?UTF-8?q?chore(realtime):=20Go=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=B4=88=EA=B8=B0=20=EC=84=A4=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- realtime/.env.example | 7 +++++++ realtime/.gitignore | 4 ++++ realtime/Makefile | 25 +++++++++++++++++++++++++ realtime/go.mod | 22 ++++++++++++++++++++++ realtime/go.sum | 38 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 realtime/.env.example create mode 100644 realtime/.gitignore create mode 100644 realtime/Makefile create mode 100644 realtime/go.mod create mode 100644 realtime/go.sum diff --git a/realtime/.env.example b/realtime/.env.example new file mode 100644 index 00000000..29584e9e --- /dev/null +++ b/realtime/.env.example @@ -0,0 +1,7 @@ +REALTIME_LISTEN_ADDR=:8081 +REALTIME_RABBITMQ_URL=amqp://stackup:stackup@localhost:5672/ +REALTIME_LOG_LEVEL=info +REALTIME_QUEUE_NAME=q.realtime.session.notify +REALTIME_SSE_PING_INTERVAL=30s +REALTIME_SSE_SLOW_CONSUMER_TIMEOUT=5s +REALTIME_SSE_BUFFER_SIZE=16 diff --git a/realtime/.gitignore b/realtime/.gitignore new file mode 100644 index 00000000..251faab0 --- /dev/null +++ b/realtime/.gitignore @@ -0,0 +1,4 @@ +bin/ +*.test +*.out +.env diff --git a/realtime/Makefile b/realtime/Makefile new file mode 100644 index 00000000..fc26360a --- /dev/null +++ b/realtime/Makefile @@ -0,0 +1,25 @@ +.PHONY: build run test fmt lint tidy clean + +GO ?= go +PKG ?= ./... + +build: + $(GO) build -o bin/realtime ./cmd/realtime + +run: + $(GO) run ./cmd/realtime + +test: + $(GO) test $(PKG) + +fmt: + $(GO) fmt $(PKG) + +lint: + $(GO) vet $(PKG) + +tidy: + $(GO) mod tidy + +clean: + rm -rf bin/ diff --git a/realtime/go.mod b/realtime/go.mod new file mode 100644 index 00000000..bde5c7a1 --- /dev/null +++ b/realtime/go.mod @@ -0,0 +1,22 @@ +module github.com/Team-StackUp/stackup/realtime + +go 1.26.3 + +require ( + github.com/caarlos0/env/v11 v11.4.1 + github.com/coder/websocket v1.8.14 + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-playground/validator/v10 v10.30.2 + github.com/joho/godotenv v1.5.1 + github.com/rabbitmq/amqp091-go v1.11.0 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect +) diff --git a/realtime/go.sum b/realtime/go.sum new file mode 100644 index 00000000..18cf92db --- /dev/null +++ b/realtime/go.sum @@ -0,0 +1,38 @@ +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8= +github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 1e53f1c945fa30677198e93d8eb201d157732a59 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:23:47 +0900 Subject: [PATCH 12/36] =?UTF-8?q?feat(realtime):=20env=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EA=B8=B0=EB=B0=98=20=EC=84=A4=EC=A0=95=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9(config)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- realtime/internal/config/config.go | 25 ++++++++ realtime/internal/config/config_test.go | 79 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 realtime/internal/config/config.go create mode 100644 realtime/internal/config/config_test.go diff --git a/realtime/internal/config/config.go b/realtime/internal/config/config.go new file mode 100644 index 00000000..6c39117a --- /dev/null +++ b/realtime/internal/config/config.go @@ -0,0 +1,25 @@ +package config + +import ( + "time" + + "github.com/caarlos0/env/v11" +) + +type Config struct { + ListenAddr string `env:"REALTIME_LISTEN_ADDR" envDefault:":8081"` + RabbitMQURL string `env:"REALTIME_RABBITMQ_URL" envDefault:"amqp://stackup:stackup@localhost:5672/"` + LogLevel string `env:"REALTIME_LOG_LEVEL" envDefault:"info"` + QueueName string `env:"REALTIME_QUEUE_NAME" envDefault:"q.realtime.session.notify"` + SSEPingInterval time.Duration `env:"REALTIME_SSE_PING_INTERVAL" envDefault:"30s"` + SSESlowConsumerTimeout time.Duration `env:"REALTIME_SSE_SLOW_CONSUMER_TIMEOUT" envDefault:"5s"` + SSEBufferSize int `env:"REALTIME_SSE_BUFFER_SIZE" envDefault:"16"` +} + +func Load() (Config, error) { + cfg := Config{} + if err := env.Parse(&cfg); err != nil { + return Config{}, err + } + return cfg, nil +} diff --git a/realtime/internal/config/config_test.go b/realtime/internal/config/config_test.go new file mode 100644 index 00000000..a31c5159 --- /dev/null +++ b/realtime/internal/config/config_test.go @@ -0,0 +1,79 @@ +package config + +import ( + "testing" + "time" +) + +func TestLoadFromEnv(t *testing.T) { + t.Setenv("REALTIME_LISTEN_ADDR", ":9090") + t.Setenv("REALTIME_RABBITMQ_URL", "amqp://x:y@h:1/") + t.Setenv("REALTIME_LOG_LEVEL", "debug") + t.Setenv("REALTIME_QUEUE_NAME", "q.test") + t.Setenv("REALTIME_SSE_PING_INTERVAL", "10s") + t.Setenv("REALTIME_SSE_SLOW_CONSUMER_TIMEOUT", "2s") + t.Setenv("REALTIME_SSE_BUFFER_SIZE", "32") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.ListenAddr != ":9090" { + t.Errorf("ListenAddr = %q, want :9090", cfg.ListenAddr) + } + if cfg.RabbitMQURL != "amqp://x:y@h:1/" { + t.Errorf("RabbitMQURL = %q", cfg.RabbitMQURL) + } + if cfg.LogLevel != "debug" { + t.Errorf("LogLevel = %q", cfg.LogLevel) + } + if cfg.QueueName != "q.test" { + t.Errorf("QueueName = %q", cfg.QueueName) + } + if cfg.SSEPingInterval != 10*time.Second { + t.Errorf("SSEPingInterval = %v", cfg.SSEPingInterval) + } + if cfg.SSESlowConsumerTimeout != 2*time.Second { + t.Errorf("SSESlowConsumerTimeout = %v", cfg.SSESlowConsumerTimeout) + } + if cfg.SSEBufferSize != 32 { + t.Errorf("SSEBufferSize = %d", cfg.SSEBufferSize) + } +} + +func TestLoadDefaults(t *testing.T) { + // Clear all overrides + for _, k := range []string{ + "REALTIME_LISTEN_ADDR", + "REALTIME_RABBITMQ_URL", + "REALTIME_LOG_LEVEL", + "REALTIME_QUEUE_NAME", + "REALTIME_SSE_PING_INTERVAL", + "REALTIME_SSE_SLOW_CONSUMER_TIMEOUT", + "REALTIME_SSE_BUFFER_SIZE", + } { + t.Setenv(k, "") + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.ListenAddr != ":8081" { + t.Errorf("default ListenAddr = %q", cfg.ListenAddr) + } + if cfg.QueueName != "q.realtime.session.notify" { + t.Errorf("default QueueName = %q", cfg.QueueName) + } + if cfg.SSEPingInterval != 30*time.Second { + t.Errorf("default SSEPingInterval = %v", cfg.SSEPingInterval) + } + if cfg.SSESlowConsumerTimeout != 5*time.Second { + t.Errorf("default SSESlowConsumerTimeout = %v", cfg.SSESlowConsumerTimeout) + } + if cfg.SSEBufferSize != 16 { + t.Errorf("default SSEBufferSize = %d", cfg.SSEBufferSize) + } +} From 6b1702ec140e3a3434c389fdb76bc183c15e445c Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:25:41 +0900 Subject: [PATCH 13/36] =?UTF-8?q?feat(realtime):=20=ED=8A=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20HTTP=20=EB=AF=B8=EB=93=A4=EC=9B=A8=EC=96=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- realtime/internal/trace/trace.go | 41 +++++++++++++++++++++ realtime/internal/trace/trace_test.go | 53 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 realtime/internal/trace/trace.go create mode 100644 realtime/internal/trace/trace_test.go diff --git a/realtime/internal/trace/trace.go b/realtime/internal/trace/trace.go new file mode 100644 index 00000000..875945a8 --- /dev/null +++ b/realtime/internal/trace/trace.go @@ -0,0 +1,41 @@ +package trace + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/http" +) + +type ctxKey struct{} + +const HeaderName = "X-Trace-Id" + +func WithContext(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, ctxKey{}, id) +} + +func FromContext(ctx context.Context) string { + if v, ok := ctx.Value(ctxKey{}).(string); ok { + return v + } + return "" +} + +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get(HeaderName) + if id == "" { + id = newID() + } + w.Header().Set(HeaderName, id) + ctx := WithContext(r.Context(), id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func newID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} diff --git a/realtime/internal/trace/trace_test.go b/realtime/internal/trace/trace_test.go new file mode 100644 index 00000000..adbbb2dc --- /dev/null +++ b/realtime/internal/trace/trace_test.go @@ -0,0 +1,53 @@ +package trace + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMiddlewareUsesIncomingHeader(t *testing.T) { + var captured string + h := Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = FromContext(r.Context()) + })) + + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("X-Trace-Id", "incoming-trace") + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if captured != "incoming-trace" { + t.Errorf("captured = %q", captured) + } + if got := rec.Header().Get("X-Trace-Id"); got != "incoming-trace" { + t.Errorf("response header = %q", got) + } +} + +func TestMiddlewareGeneratesWhenMissing(t *testing.T) { + var captured string + h := Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = FromContext(r.Context()) + })) + + req := httptest.NewRequest("GET", "/x", nil) + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if captured == "" { + t.Error("expected generated trace id, got empty") + } + if got := rec.Header().Get("X-Trace-Id"); got != captured { + t.Errorf("response header = %q, want %q", got, captured) + } +} + +func TestFromContextEmptyWithoutInjection(t *testing.T) { + if got := FromContext(context.Background()); got != "" { + t.Errorf("FromContext = %q, want empty", got) + } +} From 50f62242259d08335b1e781155652759b846e534 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:32:42 +0900 Subject: [PATCH 14/36] =?UTF-8?q?feat(realtime):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=EC=9E=90=20=EB=A0=88=EC=A7=80=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20=EC=B6=94=EA=B0=80(=EB=8A=90=EB=A6=B0=20?= =?UTF-8?q?=EC=BB=A8=EC=8A=88=EB=A8=B8=20=EB=93=9C=EB=A1=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- realtime/internal/session/registry.go | 79 +++++++++++++++ realtime/internal/session/registry_test.go | 110 +++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 realtime/internal/session/registry.go create mode 100644 realtime/internal/session/registry_test.go diff --git a/realtime/internal/session/registry.go b/realtime/internal/session/registry.go new file mode 100644 index 00000000..d614bef9 --- /dev/null +++ b/realtime/internal/session/registry.go @@ -0,0 +1,79 @@ +package session + +import ( + "sync" + "sync/atomic" + "time" +) + +type Event struct { + ID string + Type string + Data []byte +} + +type Subscriber struct { + id int64 + Ch chan Event +} + +type Registry struct { + mu sync.RWMutex + subs map[int64][]*Subscriber + nextID atomic.Int64 +} + +func NewRegistry() *Registry { + return &Registry{subs: make(map[int64][]*Subscriber)} +} + +// Subscribe registers a new subscriber for sessionID with the given channel +// buffer size and returns a *Subscriber. The caller must Unsubscribe when done. +func (r *Registry) Subscribe(sessionID int64, bufferSize int) *Subscriber { + if bufferSize <= 0 { + bufferSize = 1 + } + sub := &Subscriber{ + id: r.nextID.Add(1), + Ch: make(chan Event, bufferSize), + } + r.mu.Lock() + r.subs[sessionID] = append(r.subs[sessionID], sub) + r.mu.Unlock() + return sub +} + +func (r *Registry) Unsubscribe(sessionID int64, sub *Subscriber) { + r.mu.Lock() + defer r.mu.Unlock() + list := r.subs[sessionID] + for i, s := range list { + if s.id == sub.id { + r.subs[sessionID] = append(list[:i], list[i+1:]...) + break + } + } + if len(r.subs[sessionID]) == 0 { + delete(r.subs, sessionID) + } +} + +// Dispatch sends ev to all subscribers of sessionID. For each subscriber, +// the send waits up to slowTimeout before dropping that subscriber's delivery. +// Returns the number of subscribers that received the event. +func (r *Registry) Dispatch(sessionID int64, ev Event, slowTimeout time.Duration) int { + r.mu.RLock() + subs := append([]*Subscriber(nil), r.subs[sessionID]...) + r.mu.RUnlock() + + delivered := 0 + for _, s := range subs { + select { + case s.Ch <- ev: + delivered++ + case <-time.After(slowTimeout): + // drop + } + } + return delivered +} diff --git a/realtime/internal/session/registry_test.go b/realtime/internal/session/registry_test.go new file mode 100644 index 00000000..e15ebd7c --- /dev/null +++ b/realtime/internal/session/registry_test.go @@ -0,0 +1,110 @@ +package session + +import ( + "sync" + "testing" + "time" +) + +func TestSubscribeReceivesEvent(t *testing.T) { + r := NewRegistry() + + sub := r.Subscribe(99, 4) + defer r.Unsubscribe(99, sub) + + ev := Event{ID: "1", Type: "x", Data: []byte("hi")} + delivered := r.Dispatch(99, ev, 100*time.Millisecond) + if delivered != 1 { + t.Fatalf("delivered = %d, want 1", delivered) + } + + select { + case got := <-sub.Ch: + if got.ID != "1" || got.Type != "x" || string(got.Data) != "hi" { + t.Errorf("event = %+v", got) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("did not receive event") + } +} + +func TestDispatchToUnknownSessionDelivers0(t *testing.T) { + r := NewRegistry() + + ev := Event{ID: "1", Type: "x", Data: []byte("hi")} + if got := r.Dispatch(99, ev, 100*time.Millisecond); got != 0 { + t.Errorf("delivered = %d, want 0", got) + } +} + +func TestMultipleSubscribersReceive(t *testing.T) { + r := NewRegistry() + a := r.Subscribe(7, 4) + b := r.Subscribe(7, 4) + defer r.Unsubscribe(7, a) + defer r.Unsubscribe(7, b) + + if got := r.Dispatch(7, Event{ID: "x"}, 100*time.Millisecond); got != 2 { + t.Errorf("delivered = %d, want 2", got) + } +} + +func TestUnsubscribeStopsDelivery(t *testing.T) { + r := NewRegistry() + sub := r.Subscribe(3, 4) + r.Unsubscribe(3, sub) + + if got := r.Dispatch(3, Event{ID: "x"}, 100*time.Millisecond); got != 0 { + t.Errorf("delivered = %d, want 0", got) + } +} + +func TestSlowConsumerDoesNotBlockOthers(t *testing.T) { + r := NewRegistry() + slow := r.Subscribe(1, 1) + fast := r.Subscribe(1, 1) + defer r.Unsubscribe(1, slow) + defer r.Unsubscribe(1, fast) + + // Fill slow's buffer. + r.Dispatch(1, Event{ID: "first"}, 100*time.Millisecond) + + // Drain fast's buffer so it can receive the next message. + <-fast.Ch + + start := time.Now() + delivered := r.Dispatch(1, Event{ID: "second"}, 50*time.Millisecond) + elapsed := time.Since(start) + + // Fast consumer should have received "second", slow should be dropped. + if delivered != 1 { + t.Errorf("delivered = %d, want 1 (slow dropped)", delivered) + } + if elapsed > 200*time.Millisecond { + t.Errorf("dispatch blocked %v on slow consumer", elapsed) + } + + select { + case got := <-fast.Ch: + if got.ID != "second" { + t.Errorf("fast consumer received %v, want second", got.ID) + } + default: + t.Error("fast consumer did not receive any event") + } +} + +func TestConcurrentSubscribeUnsubscribe(t *testing.T) { + r := NewRegistry() + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s := r.Subscribe(42, 4) + r.Dispatch(42, Event{ID: "x"}, 50*time.Millisecond) + r.Unsubscribe(42, s) + }() + } + wg.Wait() +} From db9aef6479b16ade547b1f3980964879be526466 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:36:10 +0900 Subject: [PATCH 15/36] =?UTF-8?q?feat(realtime):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=EC=9E=90=20=EB=A0=88=EC=A7=80=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20=EC=B6=94=EA=B0=80(=EB=8A=90=EB=A6=B0=20?= =?UTF-8?q?=EC=BB=A8=EC=8A=88=EB=A8=B8=20=EB=93=9C=EB=A1=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement bridge package with envelope parsing and dispatcher for routing RabbitMQ messages to session subscribers. All 4 bridge tests pass with race detector enabled; full realtime suite (15 tests) passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- realtime/internal/bridge/dispatcher.go | 44 +++++++++++ realtime/internal/bridge/dispatcher_test.go | 84 +++++++++++++++++++++ realtime/internal/bridge/envelope.go | 42 +++++++++++ 3 files changed, 170 insertions(+) create mode 100644 realtime/internal/bridge/dispatcher.go create mode 100644 realtime/internal/bridge/dispatcher_test.go create mode 100644 realtime/internal/bridge/envelope.go diff --git a/realtime/internal/bridge/dispatcher.go b/realtime/internal/bridge/dispatcher.go new file mode 100644 index 00000000..2b306a37 --- /dev/null +++ b/realtime/internal/bridge/dispatcher.go @@ -0,0 +1,44 @@ +package bridge + +import ( + "encoding/json" + "time" + + "github.com/Team-StackUp/stackup/realtime/internal/session" +) + +type DispatchResult struct { + SessionID int64 + Delivered int + Envelope Envelope +} + +type Dispatcher struct { + registry *session.Registry + slowTimeout time.Duration +} + +func NewDispatcher(r *session.Registry, slowTimeout time.Duration) *Dispatcher { + return &Dispatcher{registry: r, slowTimeout: slowTimeout} +} + +func (d *Dispatcher) Dispatch(body []byte) (DispatchResult, error) { + env, err := ParseEnvelope(body) + if err != nil { + return DispatchResult{}, err + } + + sid := *env.Context.SessionID + data, _ := json.Marshal(map[string]any{ + "data": env.Payload.Data, + "traceId": env.TraceID, + }) + + delivered := d.registry.Dispatch(sid, session.Event{ + ID: env.MessageID, + Type: env.Payload.EventType, + Data: data, + }, d.slowTimeout) + + return DispatchResult{SessionID: sid, Delivered: delivered, Envelope: env}, nil +} diff --git a/realtime/internal/bridge/dispatcher_test.go b/realtime/internal/bridge/dispatcher_test.go new file mode 100644 index 00000000..f5e605d3 --- /dev/null +++ b/realtime/internal/bridge/dispatcher_test.go @@ -0,0 +1,84 @@ +package bridge + +import ( + "testing" + "time" + + "github.com/Team-StackUp/stackup/realtime/internal/session" +) + +func TestDispatchValidEnvelope(t *testing.T) { + r := session.NewRegistry() + sub := r.Subscribe(99, 4) + defer r.Unsubscribe(99, sub) + + d := NewDispatcher(r, 100*time.Millisecond) + + body := []byte(`{ + "messageId":"m1","messageType":"realtime.session.notify","version":"v1", + "traceId":"t1","publishedAt":"2026-05-08T00:00:00Z","publisher":"core", + "payload":{"eventType":"question.created","data":{"q":"hello"}}, + "context":{"sessionId":99} + }`) + + res, err := d.Dispatch(body) + if err != nil { + t.Fatalf("Dispatch err: %v", err) + } + if res.SessionID != 99 || res.Delivered != 1 { + t.Errorf("res = %+v", res) + } + + select { + case got := <-sub.Ch: + if got.ID != "m1" || got.Type != "question.created" { + t.Errorf("event = %+v", got) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("did not receive event") + } +} + +func TestDispatchUnknownSessionDelivers0(t *testing.T) { + r := session.NewRegistry() + d := NewDispatcher(r, 50*time.Millisecond) + + body := []byte(`{ + "messageId":"m2","messageType":"realtime.session.notify","version":"v1", + "traceId":"t2","publishedAt":"2026-05-08T00:00:00Z","publisher":"core", + "payload":{"eventType":"x","data":{}}, + "context":{"sessionId":404} + }`) + + res, err := d.Dispatch(body) + if err != nil { + t.Fatalf("Dispatch err: %v", err) + } + if res.Delivered != 0 { + t.Errorf("delivered = %d", res.Delivered) + } +} + +func TestDispatchInvalidJSONReturnsError(t *testing.T) { + r := session.NewRegistry() + d := NewDispatcher(r, 50*time.Millisecond) + if _, err := d.Dispatch([]byte("{not json")); err == nil { + t.Error("expected error") + } +} + +func TestDispatchMissingSessionIdReturnsError(t *testing.T) { + r := session.NewRegistry() + d := NewDispatcher(r, 50*time.Millisecond) + + body := []byte(`{ + "messageId":"m3","messageType":"realtime.session.notify","version":"v1", + "traceId":"t3","publishedAt":"2026-05-08T00:00:00Z","publisher":"core", + "payload":{"eventType":"x","data":{}}, + "context":{} + }`) + + if _, err := d.Dispatch(body); err == nil { + t.Error("expected error") + } +} diff --git a/realtime/internal/bridge/envelope.go b/realtime/internal/bridge/envelope.go new file mode 100644 index 00000000..897bfbf9 --- /dev/null +++ b/realtime/internal/bridge/envelope.go @@ -0,0 +1,42 @@ +package bridge + +import ( + "encoding/json" + "errors" +) + +type RealtimePayload struct { + EventType string `json:"eventType"` + Data json.RawMessage `json:"data"` +} + +type Context struct { + UserID *int64 `json:"userId,omitempty"` + SessionID *int64 `json:"sessionId,omitempty"` +} + +type Envelope struct { + MessageID string `json:"messageId"` + MessageType string `json:"messageType"` + Version string `json:"version"` + TraceID string `json:"traceId"` + PublishedAt string `json:"publishedAt"` + Publisher string `json:"publisher"` + Payload RealtimePayload `json:"payload"` + Context Context `json:"context"` +} + +var ( + ErrMissingSessionID = errors.New("envelope.context.sessionId is required") +) + +func ParseEnvelope(body []byte) (Envelope, error) { + var env Envelope + if err := json.Unmarshal(body, &env); err != nil { + return Envelope{}, err + } + if env.Context.SessionID == nil { + return Envelope{}, ErrMissingSessionID + } + return env, nil +} From e312e147592c58f193d10361836a64c94c9d4fde Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:39:42 +0900 Subject: [PATCH 16/36] =?UTF-8?q?feat(realtime):=20AMQP=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=B0=8F=20=EC=9E=AC=EC=97=B0=EA=B2=B0=20=EC=BB=A8?= =?UTF-8?q?=EC=8A=88=EB=A8=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- realtime/internal/messaging/connection.go | 35 ++++++++++ realtime/internal/messaging/consumer.go | 84 +++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 realtime/internal/messaging/connection.go create mode 100644 realtime/internal/messaging/consumer.go diff --git a/realtime/internal/messaging/connection.go b/realtime/internal/messaging/connection.go new file mode 100644 index 00000000..9779b429 --- /dev/null +++ b/realtime/internal/messaging/connection.go @@ -0,0 +1,35 @@ +package messaging + +import ( + "context" + "log/slog" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type Connector struct { + URL string + Backoff time.Duration +} + +func NewConnector(url string) *Connector { + return &Connector{URL: url, Backoff: 2 * time.Second} +} + +// Dial blocks until a connection is established or ctx is cancelled. +// Reconnection should be handled by the caller (see Consumer.Run). +func (c *Connector) Dial(ctx context.Context) (*amqp.Connection, error) { + for { + conn, err := amqp.Dial(c.URL) + if err == nil { + return conn, nil + } + slog.Warn("amqp.dial.retry", "err", err, "backoff", c.Backoff) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(c.Backoff): + } + } +} diff --git a/realtime/internal/messaging/consumer.go b/realtime/internal/messaging/consumer.go new file mode 100644 index 00000000..d2ecc5f3 --- /dev/null +++ b/realtime/internal/messaging/consumer.go @@ -0,0 +1,84 @@ +package messaging + +import ( + "context" + "log/slog" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type Handler interface { + Handle(body []byte) error +} + +type Consumer struct { + Connector *Connector + QueueName string + Handler Handler +} + +// Run blocks, reconnecting on connection loss until ctx is cancelled. +func (c *Consumer) Run(ctx context.Context) error { + for { + if err := c.runOnce(ctx); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + slog.Warn("amqp.session.reconnecting", "err", err) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + continue + } + return nil + } +} + +func (c *Consumer) runOnce(ctx context.Context) error { + conn, err := c.Connector.Dial(ctx) + if err != nil { + return err + } + defer conn.Close() + + ch, err := conn.Channel() + if err != nil { + return err + } + defer ch.Close() + + if _, err := ch.QueueDeclarePassive(c.QueueName, true, false, false, false, nil); err != nil { + return err + } + + deliveries, err := ch.Consume(c.QueueName, "", false, false, false, false, nil) + if err != nil { + return err + } + slog.Info("amqp.consumer.started", "queue", c.QueueName) + + closeCh := conn.NotifyClose(make(chan *amqp.Error, 1)) + + for { + select { + case <-ctx.Done(): + slog.Info("amqp.consumer.stopping") + return ctx.Err() + case err := <-closeCh: + return err + case d, ok := <-deliveries: + if !ok { + return amqp.ErrClosed + } + if err := c.Handler.Handle(d.Body); err != nil { + slog.Warn("amqp.handler.failed", "err", err) + _ = d.Nack(false, false) // drop (no requeue) + continue + } + _ = d.Ack(false) + } + } +} From 634d6daf13e603a44b929470395db09f959287c1 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:43:38 +0900 Subject: [PATCH 17/36] =?UTF-8?q?feat(realtime):=20chi=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=84=B0=20+=20health/SSE=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- realtime/internal/transport/health.go | 10 +++ realtime/internal/transport/router.go | 31 +++++++++ realtime/internal/transport/sse.go | 90 +++++++++++++++++++++++++ realtime/internal/transport/sse_test.go | 57 ++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 realtime/internal/transport/health.go create mode 100644 realtime/internal/transport/router.go create mode 100644 realtime/internal/transport/sse.go create mode 100644 realtime/internal/transport/sse_test.go diff --git a/realtime/internal/transport/health.go b/realtime/internal/transport/health.go new file mode 100644 index 00000000..249e0540 --- /dev/null +++ b/realtime/internal/transport/health.go @@ -0,0 +1,10 @@ +package transport + +import ( + "net/http" +) + +func HealthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} diff --git a/realtime/internal/transport/router.go b/realtime/internal/transport/router.go new file mode 100644 index 00000000..09622a04 --- /dev/null +++ b/realtime/internal/transport/router.go @@ -0,0 +1,31 @@ +package transport + +import ( + "log/slog" + "net/http" + + "github.com/Team-StackUp/stackup/realtime/internal/session" + "github.com/Team-StackUp/stackup/realtime/internal/trace" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func NewRouter(reg *session.Registry, sse *SSEHandler) http.Handler { + r := chi.NewRouter() + r.Use(middleware.Recoverer) + r.Use(trace.Middleware) + r.Use(loggingMiddleware) + + r.Get("/health", HealthHandler) + r.Get("/realtime/sessions/{id}", sse.ServeHTTP) + + return r +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + traceID := trace.FromContext(r.Context()) + slog.Info("http.request", "method", r.Method, "path", r.URL.Path, "trace_id", traceID) + next.ServeHTTP(w, r) + }) +} diff --git a/realtime/internal/transport/sse.go b/realtime/internal/transport/sse.go new file mode 100644 index 00000000..782a07e5 --- /dev/null +++ b/realtime/internal/transport/sse.go @@ -0,0 +1,90 @@ +package transport + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/Team-StackUp/stackup/realtime/internal/session" + "github.com/Team-StackUp/stackup/realtime/internal/trace" + "github.com/go-chi/chi/v5" +) + +type SSEHandler struct { + Registry *session.Registry + BufferSize int + PingInterval time.Duration + HeartbeatPrefix string +} + +func NewSSEHandler(r *session.Registry, bufferSize int, pingInterval time.Duration) *SSEHandler { + return &SSEHandler{ + Registry: r, + BufferSize: bufferSize, + PingInterval: pingInterval, + HeartbeatPrefix: ": ping ", + } +} + +func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + sid, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || sid <= 0 { + http.Error(w, "invalid session id", http.StatusBadRequest) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + traceID := trace.FromContext(r.Context()) + slog.Info("sse.subscribe", "session_id", sid, "trace_id", traceID) + + sub := h.Registry.Subscribe(sid, h.BufferSize) + defer h.Registry.Unsubscribe(sid, sub) + + ctx := r.Context() + ticker := time.NewTicker(h.PingInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + slog.Info("sse.unsubscribe", "session_id", sid, "reason", "client_close") + return + case <-ticker.C: + if _, err := fmt.Fprintf(w, "%s%d\n\n", h.HeartbeatPrefix, time.Now().Unix()); err != nil { + return + } + flusher.Flush() + case ev, ok := <-sub.Ch: + if !ok { + return + } + if _, err := writeSSE(w, ev); err != nil { + return + } + flusher.Flush() + } + } +} + +func writeSSE(w http.ResponseWriter, ev session.Event) (int, error) { + return fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, ev.Data) +} + +// for tests +var _ = context.Background diff --git a/realtime/internal/transport/sse_test.go b/realtime/internal/transport/sse_test.go new file mode 100644 index 00000000..c8c1387b --- /dev/null +++ b/realtime/internal/transport/sse_test.go @@ -0,0 +1,57 @@ +package transport + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Team-StackUp/stackup/realtime/internal/session" +) + +func TestSSEHandlerRejectsNonNumericID(t *testing.T) { + reg := session.NewRegistry() + sse := NewSSEHandler(reg, 4, 1*time.Second) + r := NewRouter(reg, sse) + + req := httptest.NewRequest("GET", "/realtime/sessions/abc", nil) + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } +} + +func TestSSEHandlerRejectsZeroID(t *testing.T) { + reg := session.NewRegistry() + sse := NewSSEHandler(reg, 4, 1*time.Second) + r := NewRouter(reg, sse) + + req := httptest.NewRequest("GET", "/realtime/sessions/0", nil) + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } +} + +func TestHealthEndpoint(t *testing.T) { + reg := session.NewRegistry() + sse := NewSSEHandler(reg, 4, 1*time.Second) + r := NewRouter(reg, sse) + + req := httptest.NewRequest("GET", "/health", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d", rec.Code) + } + if rec.Body.String() != `{"status":"ok"}` { + t.Errorf("body = %q", rec.Body.String()) + } +} From ac0a9bac01830ee8d49a4961e95d98524e7b8862 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:46:38 +0900 Subject: [PATCH 18/36] =?UTF-8?q?feat(realtime):=20HTTP=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=B0=8F=20AMQP=20=EC=BB=A8=EC=8A=88=EB=A8=B8=20?= =?UTF-8?q?=EC=A1=B0=EB=A6=BD(main)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- realtime/cmd/realtime/main.go | 114 ++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 realtime/cmd/realtime/main.go diff --git a/realtime/cmd/realtime/main.go b/realtime/cmd/realtime/main.go new file mode 100644 index 00000000..f9569f25 --- /dev/null +++ b/realtime/cmd/realtime/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/Team-StackUp/stackup/realtime/internal/bridge" + "github.com/Team-StackUp/stackup/realtime/internal/config" + "github.com/Team-StackUp/stackup/realtime/internal/messaging" + "github.com/Team-StackUp/stackup/realtime/internal/session" + "github.com/Team-StackUp/stackup/realtime/internal/transport" +) + +func main() { + cfg, err := config.Load() + if err != nil { + slog.Error("config.load.failed", "err", err) + os.Exit(1) + } + configureLogger(cfg.LogLevel) + + registry := session.NewRegistry() + dispatcher := bridge.NewDispatcher(registry, cfg.SSESlowConsumerTimeout) + consumer := &messaging.Consumer{ + Connector: messaging.NewConnector(cfg.RabbitMQURL), + QueueName: cfg.QueueName, + Handler: handlerAdapter{d: dispatcher}, + } + + sseHandler := transport.NewSSEHandler(registry, cfg.SSEBufferSize, cfg.SSEPingInterval) + router := transport.NewRouter(registry, sseHandler) + + srv := &http.Server{ + Addr: cfg.ListenAddr, + Handler: router, + ReadHeaderTimeout: 5 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + slog.Info("http.listen", "addr", cfg.ListenAddr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("http.serve.failed", "err", err) + stop() + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + if err := consumer.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + slog.Error("amqp.consumer.failed", "err", err) + stop() + } + }() + + <-ctx.Done() + slog.Info("shutdown.start") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + + wg.Wait() + slog.Info("shutdown.done") +} + +type handlerAdapter struct { + d *bridge.Dispatcher +} + +func (h handlerAdapter) Handle(body []byte) error { + res, err := h.d.Dispatch(body) + if err != nil { + slog.Warn("dispatch.failed", "err", err) + return nil // drop, do not requeue + } + slog.Info("dispatched", + "session_id", res.SessionID, + "delivered", res.Delivered, + "trace_id", res.Envelope.TraceID, + "event_type", res.Envelope.Payload.EventType, + ) + return nil +} + +func configureLogger(level string) { + var lvl slog.Level + switch strings.ToLower(level) { + case "debug": + lvl = slog.LevelDebug + case "warn": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}) + slog.SetDefault(slog.New(h)) +} From d3bee9cf861ad203d7551a56eeb638f763d86499 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:50:12 +0900 Subject: [PATCH 19/36] =?UTF-8?q?feat(realtime):=20=EB=A9=80=ED=8B=B0?= =?UTF-8?q?=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=A7=80=20Alpine=20Dockerfile=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(wget=20=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- realtime/Dockerfile | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 realtime/Dockerfile diff --git a/realtime/Dockerfile b/realtime/Dockerfile new file mode 100644 index 00000000..dcc5bebb --- /dev/null +++ b/realtime/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1.7 +FROM golang:1.26-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/realtime ./cmd/realtime + +FROM alpine:3.20 +RUN apk add --no-cache wget ca-certificates && \ + addgroup -S app && adduser -S app -G app +WORKDIR /app +COPY --from=builder /out/realtime /app/realtime +EXPOSE 8081 +USER app:app +ENTRYPOINT ["/app/realtime"] From bb6cdc855be9f81f2c4a778b260c5338c0fc7400 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:52:12 +0900 Subject: [PATCH 20/36] =?UTF-8?q?reword=20df880cd=20#=20docs(realtime):=20?= =?UTF-8?q?Go=20=EC=84=9C=EB=B2=84=EC=9A=A9=20CLAUDE.md=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- realtime/CLAUDE.md | 212 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 realtime/CLAUDE.md diff --git a/realtime/CLAUDE.md b/realtime/CLAUDE.md new file mode 100644 index 00000000..9b4a5bc6 --- /dev/null +++ b/realtime/CLAUDE.md @@ -0,0 +1,212 @@ +# RealTime Server — Claude 컨텍스트 + +> StackUp RealTime 서버. **Go 1.26 + chi + amqp091-go**. RabbitMQ consumer + SSE/WebSocket 서버. 본 문서 작성 시점에는 SSE만 활성, WS는 US-Session-03 / US-Voice-01에서 도입. + +상위: [`/CLAUDE.md`](../CLAUDE.md) · 횡단 관심사: [`/docs/`](../docs/README.md) + +--- + +## 1. 기술 스택 + +| 영역 | 기술 | +|------|------| +| Language | Go 1.26.3 (`go.mod`) | +| HTTP framework | `github.com/go-chi/chi/v5` | +| WebSocket | `github.com/coder/websocket` (현재 미사용, 모듈만 등록) | +| AMQP | `github.com/rabbitmq/amqp091-go` | +| Config | `github.com/caarlos0/env/v11` + godotenv | +| Logging | stdlib `log/slog` (JSON handler) | +| Validation | `github.com/go-playground/validator/v10` | +| 빌드 | `Makefile` | + +--- + +## 2. 디렉토리 구조 + +``` +realtime/ +├── go.mod go.sum Makefile Dockerfile .env.example +├── cmd/ +│ └── realtime/ +│ └── main.go 조립 (config → 컴포넌트 → run) +└── internal/ + ├── config/ env-tag 기반 Config struct + ├── transport/ chi 라우터 + 미들웨어 + SSE 핸들러 + ├── session/ sessionId → Subscriber 레지스트리 + ├── bridge/ MQ envelope 파싱 + 디스패처 + ├── messaging/ AMQP connection + reconnecting consumer + └── trace/ X-Trace-Id 미들웨어 + context helper +``` + +PDF의 Logical Architecture (Transport / Session State / Bridge / Messaging) 에 1:1 매핑. + +--- + +## 3. 모듈 의존성 그래프 + +``` +cmd/realtime ──→ transport ──→ session + ├─→ bridge ──→ session + └─→ messaging ──→ (amqp091-go) + +trace는 transport, cmd가 import +config는 cmd, internal/* 모두에서 import 가능 +``` + +원칙: +- `internal/` 하위 패키지는 다른 프로젝트에서 import 불가 (Go 표준) +- `transport`는 도메인 로직(`session`, `bridge`)에 의존하되 역방향 금지 +- `messaging`은 AMQP 라이브러리만 의존, 도메인 로직 모름 + +--- + +## 4. 책임 매트릭스 + +| 패키지 | 책임 | +|--------|------| +| `cmd/realtime` | 조립. config 로드, 컴포넌트 wiring, signal-aware shutdown | +| `internal/config` | 환경변수 → 타입 안전 Config | +| `internal/trace` | `X-Trace-Id` 추출/생성, context propagation | +| `internal/session` | sessionId → []*Subscriber. fan-out + slow consumer drop | +| `internal/transport` | chi 라우터, 미들웨어, `/health`, `/realtime/sessions/{id}` SSE | +| `internal/bridge` | RabbitMQ Envelope 파싱 + Dispatcher (sessionId 라우팅) | +| `internal/messaging` | AMQP connection + 무한 reconnect 가능한 consumer | + +--- + +## 5. 비책임 (명시적) + +- ❌ PostgreSQL 직접 접근 — 데이터가 필요하면 Core `/internal/*` API +- ❌ JWT 발급 — Core 책임. RealTime은 (도입 시) 서명 검증만 +- ❌ 비즈니스 로직 (질문 생성, 분석) — AI 또는 Core +- ❌ RabbitMQ에 publish — Core를 통해서만 (`architecture.md §4.1`) + +--- + +## 6. HTTP 엔드포인트 + +| ID | Method | Path | 책임 | 상태 | +|----|--------|------|------|------| +| - | GET | `/health` | 헬스체크 | 활성 | +| RT2 | GET | `/realtime/sessions/{id}` | SSE 작업 알림 | 활성 | +| RT1 | WS | `/realtime/sessions/{id}` | 라이브 면접 메시지 | **미구현** (US-Session-03) | +| RT3 | WS | `/realtime/sessions/{id}/audio` | 음성 스트림 | **미구현** (US-Voice-01) | + +> RT1과 RT2는 동일 path. Upgrade 헤더 유무로 분기. 본 PR은 SSE만이라 분기 미적용. + +--- + +## 7. RabbitMQ 토폴로지 + +| Queue | Bind | Consumer | +|-------|------|----------| +| `q.realtime.session.notify` | `stackup.realtime` exchange, routing key `realtime.session.*` | RealTime | + +발행자: Core 서버. envelope 스키마는 [`/docs/messaging.md §5`](../docs/messaging.md). + +--- + +## 8. SSE 와이어 포맷 + +``` +id: +event: +data: {"data": , "traceId": ""} + +``` + +Heartbeat (proxy keepalive): +``` +: ping + +``` + +--- + +## 9. 동시성·세션 라우팅 + +- `session.Registry`는 sync.RWMutex로 보호. +- 한 sessionId에 여러 SSE 연결(다중 디바이스/탭) 가능 → slice. +- slow consumer 처리: `Dispatch`가 `slowTimeout`을 초과하면 그 구독자만 drop, 다른 구독자는 영향 없음. +- 단일 인스턴스 가정. 다중 인스턴스 전환 시 fanout exchange + 인스턴스별 자기 큐 패턴 필요. + +--- + +## 10. 환경 변수 + +| 변수 | 기본값 | 용도 | +|------|--------|------| +| `REALTIME_LISTEN_ADDR` | `:8081` | HTTP 리슨 주소 | +| `REALTIME_RABBITMQ_URL` | `amqp://stackup:stackup@localhost:5672/` | AMQP 연결 | +| `REALTIME_LOG_LEVEL` | `info` | slog 레벨 | +| `REALTIME_QUEUE_NAME` | `q.realtime.session.notify` | 구독 큐 | +| `REALTIME_SSE_PING_INTERVAL` | `30s` | SSE heartbeat 주기 | +| `REALTIME_SSE_SLOW_CONSUMER_TIMEOUT` | `5s` | 구독자 send timeout | +| `REALTIME_SSE_BUFFER_SIZE` | `16` | 구독자별 채널 버퍼 | + +--- + +## 11. 빌드·실행 + +```bash +make build # bin/realtime +make run # 호스트에서 실행 +make test # go test ./... +go test ./... -race # 동시성 검증 +docker build -t stackup-realtime ./realtime +``` + +`docker compose up -d realtime`은 루트 compose에 등록되어 있음. + +--- + +## 12. 코드 스타일 + +- `gofmt` (Makefile `make fmt`) +- 패키지명은 단복수 단수 +- 외부에 노출할 타입만 `PascalCase`, 내부는 `camelCase` +- `internal/` 활용으로 외부 import 차단 +- 에러는 wrap (`fmt.Errorf("...: %w", err)`) + +--- + +## 13. 안티패턴 + +- ❌ session.Registry 외부에서 `subs` 맵 직접 조작 → API만 사용 +- ❌ goroutine 누수 → 모든 long-running goroutine은 `context.Context` 기반 종료 +- ❌ Subscriber 채널을 두 번 close → Unsubscribe는 한 번만 +- ❌ AMQP delivery에서 panic → recover middleware 또는 handler에서 catch + Nack +- ❌ blocking write to subscriber.Ch without timeout → 항상 select + slowTimeout + +--- + +## 14. 신규 기능 추가 절차 + +새 SSE 이벤트 타입 추가: +1. `messaging.md §5`에 envelope 추가 +2. Core 서버가 publish (별도 PR) +3. RealTime은 `bridge.Dispatch`가 자동 처리 (event_type만 다르고 라우팅 동일) + +새 WebSocket 엔드포인트 (RT1/RT3): +1. `transport/ws_*.go` 추가 +2. `coder/websocket`로 upgrade +3. `internal/session.Registry`를 protocol-agnostic으로 그대로 활용 + +새 환경 변수: +1. `internal/config/config.go`에 필드 + `env`/`envDefault` 태그 +2. `realtime/.env.example` 갱신 +3. 본 문서 §10 갱신 + +--- + +## 15. 현재 상태 (2026-05 기준) + +- HTTP 서버 + `/health` 활성 +- SSE `/realtime/sessions/{id}` 활성 +- AMQP `q.realtime.session.notify` consumer 활성, dispatcher → SSE fan-out 동작 +- WebSocket 미구현 +- JWT 인증 미구현 (TODO 주석) +- DLQ 없음 (parse 에러 시 drop) +- Prometheus 노출 미구현 + +각 도입 시 본 문서 갱신. From de7282a602c805977a4a3476f50c03c232309ac4 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 22:59:29 +0900 Subject: [PATCH 21/36] =?UTF-8?q?feat(infra):=20docker-compose=EC=97=90=20?= =?UTF-8?q?ai/realtime=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 +++++++ docker-compose.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/.env.example b/.env.example index db567a84..99629937 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,10 @@ MINIO_ROOT_PASSWORD=minioadmin MINIO_API_PORT=9000 MINIO_CONSOLE_PORT=9001 MINIO_BUCKET=stackup + +# AI server +AI_PORT=8000 + +# RealTime server +REALTIME_PORT=8081 +REALTIME_LOG_LEVEL=info diff --git a/docker-compose.yml b/docker-compose.yml index 0350e6e0..1c1c8890 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,6 +64,46 @@ services: - ./infra/minio/init.sh:/init.sh:ro entrypoint: ["/bin/sh", "/init.sh"] + ai: + build: ./ai + container_name: stackup-ai + ports: + - "${AI_PORT:-8000}:8000" + environment: + RABBITMQ_URL: amqp://${RABBITMQ_USER:-stackup}:${RABBITMQ_PASSWORD:-stackup}@rabbitmq:5672/ + S3_ENDPOINT_URL: http://minio:9000 + S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} + S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} + S3_BUCKET_NAME: ${MINIO_BUCKET:-stackup} + depends_on: + rabbitmq: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 10s + timeout: 5s + retries: 5 + + realtime: + build: ./realtime + container_name: stackup-realtime + ports: + - "${REALTIME_PORT:-8081}:8081" + environment: + REALTIME_LISTEN_ADDR: :8081 + REALTIME_RABBITMQ_URL: amqp://${RABBITMQ_USER:-stackup}:${RABBITMQ_PASSWORD:-stackup}@rabbitmq:5672/ + REALTIME_LOG_LEVEL: ${REALTIME_LOG_LEVEL:-info} + depends_on: + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8081/health"] + interval: 10s + timeout: 5s + retries: 5 + volumes: postgres_data: rabbitmq_data: From 0898054b86d0777a38fa510a9adab749f48824e9 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 23:04:25 +0900 Subject: [PATCH 22/36] =?UTF-8?q?fix(infra):=20compose=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EC=9A=A9=20RabbitMQ=20stackup=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/rabbitmq/definitions.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/infra/rabbitmq/definitions.json b/infra/rabbitmq/definitions.json index 1986b44a..88e92eb7 100644 --- a/infra/rabbitmq/definitions.json +++ b/infra/rabbitmq/definitions.json @@ -2,6 +2,23 @@ "vhosts": [ { "name": "/" } ], + "users": [ + { + "name": "stackup", + "password_hash": "+ViZ+UsTDxbQQtWjjlb/NrzNcJhbD+oLu9wxpxeAk0gvTgue", + "hashing_algorithm": "rabbit_password_hashing_sha256", + "tags": ["administrator"] + } + ], + "permissions": [ + { + "user": "stackup", + "vhost": "/", + "configure": ".*", + "write": ".*", + "read": ".*" + } + ], "exchanges": [ { "name": "stackup.core-to-ai", From 9a22bc17642a30b70dc40ed2c42f2140edd679fc Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 23:09:51 +0900 Subject: [PATCH 23/36] =?UTF-8?q?fix(infra):=20realtime=20=ED=97=AC?= =?UTF-8?q?=EC=8A=A4=EC=B2=B4=ED=81=AC=20wget=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95(GET=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1c1c8890..8b8ad76a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,7 +99,7 @@ services: rabbitmq: condition: service_healthy healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:8081/health"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8081/health"] interval: 10s timeout: 5s retries: 5 From c4dc0a88b0d3fe3aed2232de57b01960a744f6fb Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 23:11:17 +0900 Subject: [PATCH 24/36] =?UTF-8?q?docs(messaging):=20stackup.realtime=20exc?= =?UTF-8?q?hange=20=EB=B0=8F=20session.notify=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/messaging.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/messaging.md b/docs/messaging.md index f788ef2f..9a64eb7f 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -12,6 +12,7 @@ |----------|------| | `stackup.core-to-ai` | Core → AI 작업 요청 | | `stackup.ai-to-core` | AI → Core 결과 회신 | +| `stackup.realtime` | Core → RealTime 세션 알림 | ### Queues (durable) @@ -23,6 +24,7 @@ | `ai.generate.followup` | `stackup.core-to-ai` | `generate.followup` | AI Server | | `core.callback.analysis` | `stackup.ai-to-core` | `callback.analysis` | Core Server | | `core.callback.questions` | `stackup.ai-to-core` | `callback.questions` | Core Server | +| `q.realtime.session.notify` | `stackup.realtime` | `realtime.session.*` | RealTime Server | ### 추가 예정 (정의 시점에 본 표 갱신) @@ -30,7 +32,6 @@ |------------|------| | `ai.generate.feedback` | 세션 종료 후 종합 피드백 생성 | | `core.callback.feedback` | 피드백 콜백 | -| `core.status.notify` | AI 진행 상태 push (SSE 전달용) | | `q.dlq.*` | Dead Letter Queue (각 큐별) | --- @@ -41,8 +42,8 @@ {action}.{aggregate} ``` -`action` ∈ `analyze | generate | callback` -`aggregate` ∈ `resume | repository | questions | followup | analysis | feedback` +`action` ∈ `analyze | generate | callback | realtime` +`aggregate` ∈ `resume | repository | questions | followup | analysis | feedback | session` 새 routing key 추가 시 본 패턴 유지. @@ -103,6 +104,7 @@ | 질문 풀 생성 (US-18) | `generate.questions` | `callback.questions` | `core.callback.questions` | | 꼬리질문 생성 (US-19) | `generate.followup` | `callback.questions` | `core.callback.questions` | | 피드백 생성 (US-24) | `generate.feedback` *(예정)* | `callback.feedback` *(예정)* | `core.callback.feedback` *(예정)* | +| 세션 알림 (RT2 SSE) | `realtime.session.notify` | (없음 — 단방향 push) | `q.realtime.session.notify` | > `callback.analysis` 큐는 resume/repo 두 use case가 공유. consumer는 `payload.targetType` 으로 분기한다. @@ -266,6 +268,23 @@ } ``` +### 5.11 `realtime.session.notify` +```json +{ + "messageType": "realtime.session.notify", + "payload": { + "eventType": "question.created", + "data": { /* 임의 JSON, RealTime이 그대로 SSE data 필드에 전달 */ } + }, + "context": { "sessionId": 99, "userId": 123 } +} +``` + +- 발행자: Core 서버 (AI callback 처리 후 또는 자체 상태 변화 시) +- 소비자: RealTime 서버 (SSE 구독자에게 fan-out) +- `context.sessionId` 필수 — 라우팅 키 +- `payload.eventType`은 SSE `event:` 필드로 매핑 + --- ## 6. 재시도·DLQ 정책 From bcaedcfe8b571473f12060aacc32b58f9c8c596f Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 23:13:06 +0900 Subject: [PATCH 25/36] =?UTF-8?q?docs:=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98/=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC(SSE+WS,=20realtime/ai)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 15 +++++++-------- docs/environment.md | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a7a45280..baee3893 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -87,14 +87,13 @@ - **AI → Core**: `stackup.ai-to-core` exchange - 메시지 envelope·routing key·재시도: [`messaging.md`](./messaging.md) -### 3.3 실시간 푸시 (SSE 단일화) - -- **Frontend ← Core (또는 RealTime)**: SSE — AI 작업 상태, 면접 메시지 push -- **WebSocket 미사용** — 양방향 채널이 필요 없는 시나리오는 SSE가 더 효율적: - - 트래픽: HTTP/2 multiplexing 친화적 - - 인프라: 추가 컴포넌트(WS 서버, 핸드셰이크) 불필요 - - 클라이언트: 브라우저 EventSource가 자동 재연결 - - 단방향만 필요 (서버 → 클라이언트). 클라이언트 → 서버는 일반 REST POST로 충분 +### 3.3 실시간 푸시 (SSE + WebSocket) + +- **SSE (작업 상태)**: Frontend ← RealTime — AI 분석 진행 상태, 세션 알림. 단방향, EventSource 자동 재연결. +- **WebSocket (라이브 면접)**: Frontend ↔ RealTime — RT1은 면접 메시지 양방향 (FE answer → RealTime → Core `/internal/messages`), RT3은 음성 스트림. +- **선택 기준**: + - 단방향 push만 필요 → SSE (트래픽·인프라 단순, 자동 재연결) + - 양방향 + 저지연 필요 (라이브 면접, 음성) → WebSocket - 이벤트 스펙: [`event-stream.md`](./event-stream.md) ### 3.4 미디어 (WebRTC) diff --git a/docs/environment.md b/docs/environment.md index 19e48c15..f28568cb 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -129,14 +129,35 @@ VITE_SENTRY_DSN= # 옵션 ## 6. RealTime Server 환경 변수 -```dotenv -REALTIME_PORT=9090 -CORE_SERVER_BASE_URL=http://core:8080 -``` +| 변수 | 기본값 | 용도 | +|------|--------|------| +| `REALTIME_LISTEN_ADDR` | `:8081` | HTTP 리슨 | +| `REALTIME_RABBITMQ_URL` | `amqp://stackup:stackup@localhost:5672/` | AMQP 연결 | +| `REALTIME_LOG_LEVEL` | `info` | slog 레벨 (debug/info/warn/error) | +| `REALTIME_QUEUE_NAME` | `q.realtime.session.notify` | 구독 큐 | +| `REALTIME_SSE_PING_INTERVAL` | `30s` | SSE heartbeat 주기 | +| `REALTIME_SSE_SLOW_CONSUMER_TIMEOUT` | `5s` | 구독자 send timeout | +| `REALTIME_SSE_BUFFER_SIZE` | `16` | 구독자별 채널 버퍼 | + +루트 `.env.example`은 `REALTIME_PORT`, `REALTIME_LOG_LEVEL` 만 노출 (compose에서 매핑). + +--- + +## 7. AI Server (메시징) + +| 변수 | 기본값 | 용도 | +|------|--------|------| +| `RABBITMQ_URL` | `amqp://stackup:stackup@localhost:5672/` | AMQP 연결 | +| `AI_QUEUE_RESUME` | `ai.analyze.resume` | 이력서 분석 큐 | +| `AI_QUEUE_PREFETCH` | `10` | consumer prefetch | +| `AI_CALLBACK_EXCHANGE` | `stackup.ai-to-core` | 콜백 발행 exchange | +| `AI_CALLBACK_ROUTING_ANALYSIS` | `callback.analysis` | 콜백 routing key | +| `AI_PUBLISHER_NAME` | `ai-server` | envelope.publisher 값 | +| `AI_IDEMPOTENCY_LRU_SIZE` | `1024` | LRU 멱등 캐시 크기 | --- -## 7. .env 파일 관리 룰 +## 8. .env 파일 관리 룰 - `.env.example` (루트, frontend, ai 각각): **커밋 O** — 키 이름 + dummy 값 - `.env`, `.env.local`, `.env.production`: **커밋 X** — `.gitignore`로 차단 @@ -144,7 +165,7 @@ CORE_SERVER_BASE_URL=http://core:8080 --- -## 8. 비밀 관리 (운영) +## 9. 비밀 관리 (운영) | 환경 | 저장소 | |------|--------| @@ -156,7 +177,7 @@ CORE_SERVER_BASE_URL=http://core:8080 --- -## 9. 설정 검증 +## 10. 설정 검증 서비스 부팅 시점에 필수 환경변수 검증: @@ -182,7 +203,7 @@ class Settings(BaseSettings): --- -## 10. 개발 환경 부트스트랩 +## 11. 개발 환경 부트스트랩 ```bash cp .env.example .env # 루트 From 12ce0445880cc6bbe453657abc7fb5c428e817af Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 23:15:11 +0900 Subject: [PATCH 26/36] =?UTF-8?q?docs(claude):=20realtime+ai=20bootstrap?= =?UTF-8?q?=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 9 ++++----- ai/CLAUDE.md | 15 ++++++++------- infra/CLAUDE.md | 6 +++++- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 52222823..6160a4e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,11 +41,10 @@ PostgreSQL+pgvector · S3/MinIO |--------|------|------| | 프론트엔드 | [`frontend/CLAUDE.md`](./frontend/CLAUDE.md) | React 19 + TypeScript + Vite, FSD 구조 | | 백엔드 (Core) | [`backend/CLAUDE.md`](./backend/CLAUDE.md) | Java 21 + Spring Boot 4 + JPA + QueryDSL | +| RealTime 서버 | [`realtime/CLAUDE.md`](./realtime/CLAUDE.md) | Go 1.26 + chi + amqp091-go, SSE/WebSocket | | AI 서버 | [`ai/CLAUDE.md`](./ai/CLAUDE.md) | Python 3.13 + FastAPI + LangChain | | 인프라 | [`infra/CLAUDE.md`](./infra/CLAUDE.md) | Docker Compose: PG(+pgvector), RabbitMQ, MinIO | -> **RealTime Server (Go)** 는 아직 구현 디렉토리 없음. 추가 시 `realtime/CLAUDE.md` 생성. - 각 레이어 하위 슬라이스/도메인 패키지에도 `CLAUDE.md`가 배치되어 있을 수 있다. 작업할 디렉토리에서 가장 가까운 것을 우선 읽는다. --- @@ -144,9 +143,9 @@ cd frontend && npm install && npm run dev - Phase 1 (MVP) 진행 중. US-01 ~ US-20 우선. - 디자인 시스템 토큰 파일 미생성 — 디자인 적용 첫 PR에서 `frontend/src/app/styles/tokens.css` 생성. - Spring Security, RabbitMQ starter, Flyway는 백엔드 기능 작성 PR과 함께 도입. -- `infra/rabbitmq/definitions.json`은 현재 큐 6개 정의 (resume/repo 분석, questions/followup 생성, 콜백 2개). 피드백 큐는 US-24 작업 시 추가. +- `infra/rabbitmq/definitions.json`은 현재 exchange 3개 (`stackup.core-to-ai`, `stackup.ai-to-core`, `stackup.realtime`), 큐 7개 정의. 피드백 큐는 US-24 작업 시 추가. - **Redis 미사용** — 휘발성 데이터(OAuth state, 멱등 키, 질문 풀 캐시)는 PostgreSQL의 short-lived 레코드 또는 Core 서버 인메모리로 처리. -- 실시간 푸시는 **SSE 단일화** (양방향 WebSocket 미사용). 미디어 스트림만 WebRTC. -- `RealTime Server (Go)`는 아직 디렉토리 없음. SSE/WebRTC가 필요한 시점에 작성. +- 실시간 푸시는 **SSE + WebSocket 병행**. SSE는 작업 상태(RT2), WebSocket은 라이브 면접 메시지(RT1) 및 음성 스트림(RT3). 미디어 시그널링은 추후 WebRTC 도입 시점에 검토. +- `RealTime Server (Go)`는 `realtime/` 디렉토리에 부트스트랩 완료. 현재 SSE만 활성, WS 엔드포인트는 US-Session-03 / US-Voice-01에서 도입. 문서 변경 원칙: 코드와 함께 같은 PR에 포함. 단일 출처 원칙(SSOT) 유지. diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index 041549d6..f416aab8 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -290,12 +290,13 @@ docker run --env-file .env -p 8000:8000 stackup-ai --- -## 16. 현재 상태 (2026-04 기준) - -- FastAPI 부트스트랩 + 헬스체크만 구현 -- RabbitMQ consumer 미구현 → US-09(이력서 분석) 작업 시 도입 -- LangChain 기본 import만 있음, 체인·프롬프트 정의 0 -- pgvector 연동 미구현 -- 음성 모듈은 Phase 2에서 본격 작성 +## 16. 현재 상태 (2026-05 기준) + +- FastAPI 부트스트랩 + 헬스체크 +- RabbitMQ consumer **echo만** 구현: `ai.analyze.resume` → `callback.analysis` 즉시 echo 응답 (멱등 LRU + structlog) +- 비즈니스 로직 (PDF 파싱·LLM·임베딩·S3·pgvector) 미구현 — US-09 본 구현 PR에서 추가 +- `analyze.repository` / `generate.questions` / `generate.followup` consumer는 큐 정의만 존재, 코드 없음 +- LangChain import만 있음, 체인·프롬프트 정의 0 +- 음성 모듈은 Phase 2 각 도입 시 본 문서 갱신. diff --git a/infra/CLAUDE.md b/infra/CLAUDE.md index 3d882535..056e6053 100644 --- a/infra/CLAUDE.md +++ b/infra/CLAUDE.md @@ -36,9 +36,11 @@ docker-compose.yml # 모든 인프라 서비스 + 헬스체크 | `rabbitmq` | `rabbitmq:management` (커스텀 빌드) | 5672 / 15672 | Core ↔ AI 비동기 메시징 | | `minio` | `minio/minio` (커스텀 빌드) | 9000 / 9001 | S3 호환 객체 스토리지 | | `minio-init` | `minio/mc` | — | 부트스트랩: 버킷 생성 후 종료 | +| `ai` | `./ai` 빌드 | 8000 | AI Server (FastAPI) | +| `realtime` | `./realtime` 빌드 | 8081 | RealTime Server (Go) | > **Redis 미사용** — 휘발성 데이터(OAuth state, 멱등 키, 질문 풀 캐시)는 PostgreSQL의 short-lived 레코드 또는 Core 서버 인메모리로 처리. 본 의사결정 배경: 컴포넌트 단순화 (1개 줄임), 운영 부담 감소. -> **AI 서버, Core 서버는 docker-compose 미포함** (현재는 호스트에서 직접 실행). 향후 추가 시 본 표에 등록. +> **AI 서버**는 docker-compose 포함 (`stackup-ai`). **RealTime 서버**도 포함 (`stackup-realtime`). **Core 서버**는 현재 호스트에서 직접 실행. --- @@ -99,6 +101,7 @@ docker exec -i stackup-postgres psql -U stackup stackup < backup.sql **Exchanges (topic, durable)**: - `stackup.core-to-ai` — Core → AI 작업 요청 - `stackup.ai-to-core` — AI → Core 결과 회신 +- `stackup.realtime` — Core → RealTime 세션 알림 **Queues (durable)**: | Queue | Bound to | Routing Key | Consumer | @@ -109,6 +112,7 @@ docker exec -i stackup-postgres psql -U stackup stackup < backup.sql | `ai.generate.followup` | `stackup.core-to-ai` | `generate.followup` | AI Server | | `core.callback.analysis` | `stackup.ai-to-core` | `callback.analysis` | Core Server | | `core.callback.questions` | `stackup.ai-to-core` | `callback.questions` | Core Server | +| `q.realtime.session.notify` | `stackup.realtime` | `realtime.session.*` | RealTime Server | > 추가 큐(피드백, 상태 알림, DLQ)는 정의 시점에 본 표 갱신. From d64611fed9c9c432125c9b6b861b4c0e932a1bb4 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Fri, 8 May 2026 23:48:50 +0900 Subject: [PATCH 27/36] =?UTF-8?q?chore:=20.gitignore=EC=97=90=20.env=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 656b2e2c..418d58d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .vscode .claude -# Local worktrees for parallel feature development -.worktrees/ \ No newline at end of file +.worktrees/ +.env +!.env.example \ No newline at end of file From b78d0e4ffb795e2c623b460f9214ffc36111d8c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 11:27:35 +0000 Subject: [PATCH 28/36] fix: add qos and close handling in realtime consumer Agent-Logs-Url: https://github.com/Team-StackUp/stackup/sessions/f38bbbdd-14f5-46b7-beae-e66a9a7870c7 Co-authored-by: csh1668 <18442452+csh1668@users.noreply.github.com> --- realtime/internal/messaging/consumer.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/realtime/internal/messaging/consumer.go b/realtime/internal/messaging/consumer.go index d2ecc5f3..96939718 100644 --- a/realtime/internal/messaging/consumer.go +++ b/realtime/internal/messaging/consumer.go @@ -18,6 +18,8 @@ type Consumer struct { Handler Handler } +const prefetchCount = 1 + // Run blocks, reconnecting on connection loss until ctx is cancelled. func (c *Consumer) Run(ctx context.Context) error { for { @@ -54,21 +56,28 @@ func (c *Consumer) runOnce(ctx context.Context) error { return err } + if err := ch.Qos(prefetchCount, 0, false); err != nil { + return err + } + deliveries, err := ch.Consume(c.QueueName, "", false, false, false, false, nil) if err != nil { return err } slog.Info("amqp.consumer.started", "queue", c.QueueName) - closeCh := conn.NotifyClose(make(chan *amqp.Error, 1)) + connCloseCh := conn.NotifyClose(make(chan *amqp.Error, 1)) + chCloseCh := ch.NotifyClose(make(chan *amqp.Error, 1)) for { select { case <-ctx.Done(): slog.Info("amqp.consumer.stopping") return ctx.Err() - case err := <-closeCh: - return err + case err, ok := <-connCloseCh: + return normalizeCloseError(err, ok) + case err, ok := <-chCloseCh: + return normalizeCloseError(err, ok) case d, ok := <-deliveries: if !ok { return amqp.ErrClosed @@ -82,3 +91,10 @@ func (c *Consumer) runOnce(ctx context.Context) error { } } } + +func normalizeCloseError(err *amqp.Error, ok bool) error { + if !ok || err == nil { + return amqp.ErrClosed + } + return err +} From f48a4e86e450750c232c25845c6aff9c9cb71ec5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 11:28:44 +0000 Subject: [PATCH 29/36] chore: clarify qos prefetch handling Agent-Logs-Url: https://github.com/Team-StackUp/stackup/sessions/f38bbbdd-14f5-46b7-beae-e66a9a7870c7 Co-authored-by: csh1668 <18442452+csh1668@users.noreply.github.com> --- realtime/go.mod | 13 ----------- realtime/go.sum | 30 ------------------------- realtime/internal/messaging/consumer.go | 5 +++-- 3 files changed, 3 insertions(+), 45 deletions(-) diff --git a/realtime/go.mod b/realtime/go.mod index bde5c7a1..f5589e42 100644 --- a/realtime/go.mod +++ b/realtime/go.mod @@ -4,19 +4,6 @@ go 1.26.3 require ( github.com/caarlos0/env/v11 v11.4.1 - github.com/coder/websocket v1.8.14 github.com/go-chi/chi/v5 v5.2.5 - github.com/go-playground/validator/v10 v10.30.2 - github.com/joho/godotenv v1.5.1 github.com/rabbitmq/amqp091-go v1.11.0 ) - -require ( - github.com/gabriel-vasile/mimetype v1.4.13 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect -) diff --git a/realtime/go.sum b/realtime/go.sum index 18cf92db..18154912 100644 --- a/realtime/go.sum +++ b/realtime/go.sum @@ -1,38 +1,8 @@ github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= -github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= -github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8= github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/realtime/internal/messaging/consumer.go b/realtime/internal/messaging/consumer.go index 96939718..0bb8793e 100644 --- a/realtime/internal/messaging/consumer.go +++ b/realtime/internal/messaging/consumer.go @@ -18,7 +18,7 @@ type Consumer struct { Handler Handler } -const prefetchCount = 1 +const qosPrefetchCount = 1 // Run blocks, reconnecting on connection loss until ctx is cancelled. func (c *Consumer) Run(ctx context.Context) error { @@ -56,7 +56,8 @@ func (c *Consumer) runOnce(ctx context.Context) error { return err } - if err := ch.Qos(prefetchCount, 0, false); err != nil { + // prefetchCount=1 limits in-flight deliveries; prefetchSize=0 uses count; global=false applies per channel. + if err := ch.Qos(qosPrefetchCount, 0, false); err != nil { return err } From 2cc29fbc072ee0a0ffb6ef637e42fd75de4a6524 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 11:29:25 +0000 Subject: [PATCH 30/36] chore: restore realtime go module files Agent-Logs-Url: https://github.com/Team-StackUp/stackup/sessions/f38bbbdd-14f5-46b7-beae-e66a9a7870c7 Co-authored-by: csh1668 <18442452+csh1668@users.noreply.github.com> --- realtime/go.mod | 13 +++++++++++++ realtime/go.sum | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/realtime/go.mod b/realtime/go.mod index f5589e42..bde5c7a1 100644 --- a/realtime/go.mod +++ b/realtime/go.mod @@ -4,6 +4,19 @@ go 1.26.3 require ( github.com/caarlos0/env/v11 v11.4.1 + github.com/coder/websocket v1.8.14 github.com/go-chi/chi/v5 v5.2.5 + github.com/go-playground/validator/v10 v10.30.2 + github.com/joho/godotenv v1.5.1 github.com/rabbitmq/amqp091-go v1.11.0 ) + +require ( + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect +) diff --git a/realtime/go.sum b/realtime/go.sum index 18154912..18cf92db 100644 --- a/realtime/go.sum +++ b/realtime/go.sum @@ -1,8 +1,38 @@ github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8= github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 998afe8071a0bbe6a9ae0124db3eb1fdc5031844 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 11:30:29 +0000 Subject: [PATCH 31/36] chore: refine qos naming and comment Agent-Logs-Url: https://github.com/Team-StackUp/stackup/sessions/f38bbbdd-14f5-46b7-beae-e66a9a7870c7 Co-authored-by: csh1668 <18442452+csh1668@users.noreply.github.com> --- realtime/internal/messaging/consumer.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/realtime/internal/messaging/consumer.go b/realtime/internal/messaging/consumer.go index 0bb8793e..a848e2c1 100644 --- a/realtime/internal/messaging/consumer.go +++ b/realtime/internal/messaging/consumer.go @@ -18,7 +18,7 @@ type Consumer struct { Handler Handler } -const qosPrefetchCount = 1 +const prefetchCount = 1 // Run blocks, reconnecting on connection loss until ctx is cancelled. func (c *Consumer) Run(ctx context.Context) error { @@ -56,8 +56,8 @@ func (c *Consumer) runOnce(ctx context.Context) error { return err } - // prefetchCount=1 limits in-flight deliveries; prefetchSize=0 uses count; global=false applies per channel. - if err := ch.Qos(qosPrefetchCount, 0, false); err != nil { + // Limit in-flight deliveries to 1 (prefetch count), with no size limit (prefetch size 0), applied per consumer. + if err := ch.Qos(prefetchCount, 0, false); err != nil { return err } From 31362eb5bd90d1216262f7975a10637a8d10ad05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 11:31:28 +0000 Subject: [PATCH 32/36] chore: clarify qos intent and close handling Agent-Logs-Url: https://github.com/Team-StackUp/stackup/sessions/f38bbbdd-14f5-46b7-beae-e66a9a7870c7 Co-authored-by: csh1668 <18442452+csh1668@users.noreply.github.com> --- realtime/internal/messaging/consumer.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/realtime/internal/messaging/consumer.go b/realtime/internal/messaging/consumer.go index a848e2c1..212216a1 100644 --- a/realtime/internal/messaging/consumer.go +++ b/realtime/internal/messaging/consumer.go @@ -18,7 +18,7 @@ type Consumer struct { Handler Handler } -const prefetchCount = 1 +const qosPrefetchCount = 1 // Run blocks, reconnecting on connection loss until ctx is cancelled. func (c *Consumer) Run(ctx context.Context) error { @@ -56,8 +56,9 @@ func (c *Consumer) runOnce(ctx context.Context) error { return err } - // Limit in-flight deliveries to 1 (prefetch count), with no size limit (prefetch size 0), applied per consumer. - if err := ch.Qos(prefetchCount, 0, false); err != nil { + // Limit in-flight deliveries to 1 to preserve ordering and avoid overwhelming SSE dispatch. + // Prefetch size is 0 (count-based), and global=false applies per consumer. + if err := ch.Qos(qosPrefetchCount, 0, false); err != nil { return err } @@ -93,6 +94,7 @@ func (c *Consumer) runOnce(ctx context.Context) error { } } +// normalizeCloseError treats nil/closed notifications as ErrClosed to trigger reconnect. func normalizeCloseError(err *amqp.Error, ok bool) error { if !ok || err == nil { return amqp.ErrClosed From 4929f667b004eac3acea9899323736c00debd5bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 11:32:15 +0000 Subject: [PATCH 33/36] docs: document qos prefetch constant Agent-Logs-Url: https://github.com/Team-StackUp/stackup/sessions/f38bbbdd-14f5-46b7-beae-e66a9a7870c7 Co-authored-by: csh1668 <18442452+csh1668@users.noreply.github.com> --- realtime/internal/messaging/consumer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/realtime/internal/messaging/consumer.go b/realtime/internal/messaging/consumer.go index 212216a1..79a08e5e 100644 --- a/realtime/internal/messaging/consumer.go +++ b/realtime/internal/messaging/consumer.go @@ -18,6 +18,7 @@ type Consumer struct { Handler Handler } +// qosPrefetchCount limits in-flight deliveries to preserve ordering and avoid overwhelming SSE dispatch. const qosPrefetchCount = 1 // Run blocks, reconnecting on connection loss until ctx is cancelled. @@ -56,7 +57,6 @@ func (c *Consumer) runOnce(ctx context.Context) error { return err } - // Limit in-flight deliveries to 1 to preserve ordering and avoid overwhelming SSE dispatch. // Prefetch size is 0 (count-based), and global=false applies per consumer. if err := ch.Qos(qosPrefetchCount, 0, false); err != nil { return err From bf494f4ef45a315341bae2af9964b97b357567af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 11:33:08 +0000 Subject: [PATCH 34/36] chore: improve qos error context Agent-Logs-Url: https://github.com/Team-StackUp/stackup/sessions/f38bbbdd-14f5-46b7-beae-e66a9a7870c7 Co-authored-by: csh1668 <18442452+csh1668@users.noreply.github.com> --- realtime/internal/messaging/consumer.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/realtime/internal/messaging/consumer.go b/realtime/internal/messaging/consumer.go index 79a08e5e..636385f0 100644 --- a/realtime/internal/messaging/consumer.go +++ b/realtime/internal/messaging/consumer.go @@ -2,6 +2,7 @@ package messaging import ( "context" + "fmt" "log/slog" "time" @@ -18,7 +19,7 @@ type Consumer struct { Handler Handler } -// qosPrefetchCount limits in-flight deliveries to preserve ordering and avoid overwhelming SSE dispatch. +// qosPrefetchCount limits in-flight deliveries to preserve ordering and prevent consumer overload. const qosPrefetchCount = 1 // Run blocks, reconnecting on connection loss until ctx is cancelled. @@ -59,7 +60,7 @@ func (c *Consumer) runOnce(ctx context.Context) error { // Prefetch size is 0 (count-based), and global=false applies per consumer. if err := ch.Qos(qosPrefetchCount, 0, false); err != nil { - return err + return fmt.Errorf("amqp.qos.failed (prefetch=%d): %w", qosPrefetchCount, err) } deliveries, err := ch.Consume(c.QueueName, "", false, false, false, false, nil) @@ -94,7 +95,8 @@ func (c *Consumer) runOnce(ctx context.Context) error { } } -// normalizeCloseError treats nil/closed notifications as ErrClosed to trigger reconnect. +// normalizeCloseError converts nil or closed notifications to ErrClosed because the AMQP client may +// send nil on graceful shutdown or close the channel on termination; both require reconnect. func normalizeCloseError(err *amqp.Error, ok bool) error { if !ok || err == nil { return amqp.ErrClosed From c343a57e81ee4a3e018f95160abda592c0542282 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 11:34:02 +0000 Subject: [PATCH 35/36] chore: refine qos error messaging Agent-Logs-Url: https://github.com/Team-StackUp/stackup/sessions/f38bbbdd-14f5-46b7-beae-e66a9a7870c7 Co-authored-by: csh1668 <18442452+csh1668@users.noreply.github.com> --- realtime/internal/messaging/consumer.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/realtime/internal/messaging/consumer.go b/realtime/internal/messaging/consumer.go index 636385f0..ea1112b1 100644 --- a/realtime/internal/messaging/consumer.go +++ b/realtime/internal/messaging/consumer.go @@ -60,7 +60,7 @@ func (c *Consumer) runOnce(ctx context.Context) error { // Prefetch size is 0 (count-based), and global=false applies per consumer. if err := ch.Qos(qosPrefetchCount, 0, false); err != nil { - return fmt.Errorf("amqp.qos.failed (prefetch=%d): %w", qosPrefetchCount, err) + return fmt.Errorf("set qos prefetch=%d: %w", qosPrefetchCount, err) } deliveries, err := ch.Consume(c.QueueName, "", false, false, false, false, nil) @@ -97,6 +97,7 @@ func (c *Consumer) runOnce(ctx context.Context) error { // normalizeCloseError converts nil or closed notifications to ErrClosed because the AMQP client may // send nil on graceful shutdown or close the channel on termination; both require reconnect. +// err is the notification error and ok indicates whether the notification channel is still open. func normalizeCloseError(err *amqp.Error, ok bool) error { if !ok || err == nil { return amqp.ErrClosed From 8eda22295d7e92368248c409738b339cb261ad16 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Mon, 11 May 2026 20:59:41 +0900 Subject: [PATCH 36/36] =?UTF-8?q?fix(ai):=20lifespan=EC=97=90=EC=84=9C=20r?= =?UTF-8?q?untime.start()=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=A2=80?= =?UTF-8?q?=EB=B9=84=20connection=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runtime.start()가 부분 초기화(connection open 후 exchange/queue declare 실패) 단계에서 raise하면 finally 블록 진입 불가로 stop()이 호출되지 않아 AMQP connection이 좀비가 되던 문제 수정. MessagingRuntime.stop()은 내부 상태 None 가드가 있어 부분 초기화 상태에서도 안전하다. PR #6 review (Jaeho-Site) 대응. --- ai/src/ai_server/main.py | 2 +- ai/tests/test_lifespan.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 ai/tests/test_lifespan.py diff --git a/ai/src/ai_server/main.py b/ai/src/ai_server/main.py index 49673a52..9046b7ed 100644 --- a/ai/src/ai_server/main.py +++ b/ai/src/ai_server/main.py @@ -15,9 +15,9 @@ def _build_lifespan(settings: Settings): @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: runtime = MessagingRuntime(settings) - await runtime.start() app.state.messaging = runtime try: + await runtime.start() yield finally: await runtime.stop() diff --git a/ai/tests/test_lifespan.py b/ai/tests/test_lifespan.py new file mode 100644 index 00000000..974a7270 --- /dev/null +++ b/ai/tests/test_lifespan.py @@ -0,0 +1,38 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import FastAPI + +from ai_server.config.settings import Settings +from ai_server.main import _build_lifespan + + +@pytest.mark.asyncio +async def test_lifespan_calls_stop_when_start_fails() -> None: + """start() 가 도중에 raise 해도 stop() 이 호출되어 좀비 connection 을 막아야 한다.""" + runtime_mock = AsyncMock() + runtime_mock.start.side_effect = RuntimeError("queue declare failed") + + with patch("ai_server.main.MessagingRuntime", return_value=runtime_mock): + lifespan = _build_lifespan(Settings()) + app = FastAPI() + with pytest.raises(RuntimeError, match="queue declare failed"): + async with lifespan(app): + pass + + runtime_mock.start.assert_awaited_once() + runtime_mock.stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_lifespan_calls_stop_on_normal_shutdown() -> None: + runtime_mock = AsyncMock() + + with patch("ai_server.main.MessagingRuntime", return_value=runtime_mock): + lifespan = _build_lifespan(Settings()) + app = FastAPI() + async with lifespan(app): + assert app.state.messaging is runtime_mock + + runtime_mock.start.assert_awaited_once() + runtime_mock.stop.assert_awaited_once()