-
Notifications
You must be signed in to change notification settings - Fork 2
feat: 실시간 알림(Realtime) 및 AI 메시징 인프라 구축 #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
36 commits
Select commit
Hold shift + click to select a range
b7deeca
chore: ignore local .worktrees/ for parallel feature development
csh1668 21b2d4e
feat(infra): stackup.realtime exchange 및 session.notify 큐 추가
csh1668 ddd7507
feat(ai): 메시징 관련 설정 추가
csh1668 92684bd
feat(ai): envelope 및 메시지 컨텍스트 Pydantic 모델 추가
csh1668 5bdbe0c
feat(ai): 이력서 분석 요청/콜백 페이로드 추가
csh1668 6d141c8
refactor(ai): 공용 camel_config 헬퍼로 분리
csh1668 3add400
feat(ai): messageId 중복 제거용 LRU 멱등 스토어 추가
csh1668 6f279e2
feat(ai): aio-pika 연결 래퍼 및 콜백 퍼블리셔 추가
csh1668 a1beff7
feat(ai): 멱등 처리 포함 resume echo 컨슈머 추가
csh1668 e43dd29
feat(ai): FastAPI lifespan에 메시징 런타임 연결
csh1668 9d34789
chore(realtime): Go 모듈 초기 설정 및 의존성 추가
csh1668 1e53f1c
feat(realtime): env 태그 기반 설정 로딩(config) 추가
csh1668 6b1702e
feat(realtime): 트레이스 컨텍스트 및 HTTP 미들웨어 추가
csh1668 50f6224
feat(realtime): 세션 구독자 레지스트리 추가(느린 컨슈머 드롭)
csh1668 db9aef6
feat(realtime): 세션 구독자 레지스트리 추가(느린 컨슈머 드롭)
csh1668 e312e14
feat(realtime): AMQP 연결 및 재연결 컨슈머 추가
csh1668 634d6da
feat(realtime): chi 라우터 + health/SSE 엔드포인트 추가
csh1668 ac0a9ba
feat(realtime): HTTP 서버 및 AMQP 컨슈머 조립(main) 추가
csh1668 d3bee9c
feat(realtime): 멀티스테이지 Alpine Dockerfile 추가(wget 헬스체크)
csh1668 bb6cdc8
reword df880cd # docs(realtime): Go 서버용 CLAUDE.md 추가
csh1668 de7282a
feat(infra): docker-compose에 ai/realtime 서비스 추가
csh1668 0898054
fix(infra): compose 인증용 RabbitMQ stackup 사용자 추가
csh1668 9a22bc1
fix(infra): realtime 헬스체크 wget 호출 방식 수정(GET 사용)
csh1668 c4dc0a8
docs(messaging): stackup.realtime exchange 및 session.notify 스키마 추가
csh1668 bcaedcf
docs: 아키텍처/환경변수 정리(SSE+WS, realtime/ai)
csh1668 12ce044
docs(claude): realtime+ai bootstrap 컨텍스트 정렬
csh1668 d64611f
chore: .gitignore에 .env 추가
csh1668 b78d0e4
fix: add qos and close handling in realtime consumer
Copilot f48a4e8
chore: clarify qos prefetch handling
Copilot 2cc29fb
chore: restore realtime go module files
Copilot 998afe8
chore: refine qos naming and comment
Copilot 31362eb
chore: clarify qos intent and close handling
Copilot 4929f66
docs: document qos prefetch constant
Copilot bf494f4
chore: improve qos error context
Copilot c343a57
chore: refine qos error messaging
Copilot 8eda222
fix(ai): lifespan에서 runtime.start() 실패 시 좀비 connection 정리
csh1668 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,6 @@ | ||
| .vscode | ||
| .claude | ||
| .claude | ||
|
|
||
| .worktrees/ | ||
| .env | ||
| !.env.example |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}" |
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
runtime.start() 실패 시 finally 블록 진입 불가로 runtime.stop()이 호출되지 않고 RabbitMQ 연결이 좀비가 되는 문제가 있을 것 같아요.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
좀비 connection 막도록 try/finally 진입 순서를 변경해 runtime.start() 실패 시에도 stop() 이 호출되도록 수정했습니다. MessagingRuntime.stop() 은 내부 상태 None 가드가 있어 부분 초기화에서도 안전합니다. 회귀 방지 테스트(ai/tests/test_lifespan.py) 추가. commit 8eda222.