Skip to content

Commit 7a3352a

Browse files
Julina MaharjanJulina Maharjan
authored andcommitted
feat(storage): Week 4 — add MLflow experiment & model management (Week 4)
New storage/ component (storage -> shared only; api -> storage; inference untouched). MLflowTracker does fire-and-forget inference/prompt logging on the request path (async via to_thread, best-effort log-and-drop, bounded pending tasks), plus experiment-tracking and model/adapter registry scaffolding. Wired through api.main lifespan + generation service; ATLAS_MLFLOW_* config, off by default. Uncommented + wired the compose MLflow service (self-contained sqlite store; host port 5001 to avoid macOS AirPlay). Both Dockerfiles copy storage/ and gain an optional INCLUDE_MLFLOW arg. Hot-path logging uses MlflowClient with explicit run ids (not the fluent global-active-run API) for concurrency safety. Refs: Week 4 Tests: tests/storage/mlflow/ + tests/api/test_inference_logging.py
1 parent 663c275 commit 7a3352a

19 files changed

Lines changed: 1211 additions & 39 deletions

.env.example

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,21 @@ ATLAS_TRUST_REMOTE_CODE=false
3838
ATLAS_ENABLE_BATCHING=true
3939
ATLAS_MAX_BATCH_SIZE=16
4040
ATLAS_BATCH_WAIT_MS=5
41+
42+
# --- MLflow: experiment & model tracking (Week 4) ---
43+
# Off by default: mock/CI and hosts without `mlflow` installed run unchanged.
44+
# Enable to log inference (params/latency/tokens/output) + track runs/registry.
45+
# Needs `pip install -r requirements-mlflow.txt` and a reachable tracking store.
46+
ATLAS_MLFLOW_ENABLED=false
47+
# Tracking server or store URI. Empty + enabled => local file store "file:./mlruns".
48+
# With docker compose, point at the bundled service: http://mlflow:5000
49+
ATLAS_MLFLOW_TRACKING_URI=
50+
# ATLAS_MLFLOW_REGISTRY_URI= # defaults to the tracking URI
51+
ATLAS_MLFLOW_EXPERIMENT=atlasai-inference
52+
# Per-request inference logging (fire-and-forget, never blocks the request path)
53+
ATLAS_MLFLOW_LOG_INFERENCE=true
54+
# Persist prompt + completion text (set false to log only params/metrics)
55+
ATLAS_MLFLOW_LOG_PROMPTS=true
56+
ATLAS_MLFLOW_MAX_TEXT_CHARS=2000
57+
# Backpressure: cap concurrent in-flight log tasks (excess dropped, not blocked)
58+
ATLAS_MLFLOW_MAX_PENDING_LOGS=100

api/main.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from __future__ import annotations
1212

13+
import asyncio
1314
from contextlib import asynccontextmanager
1415
from typing import AsyncIterator
1516

@@ -22,6 +23,7 @@
2223
from shared.errors import register_exception_handlers
2324
from shared.logging import configure_logging, get_logger
2425
from shared.rate_limit import RateLimiter
26+
from storage.mlflow import MLflowTracker
2527

2628
logger = get_logger(__name__)
2729

@@ -36,14 +38,20 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
3638
"""Load the inference engine on startup; release it on shutdown."""
3739
settings: Settings = app.state.settings
3840
engine: InferenceEngine = app.state.engine
41+
tracker: MLflowTracker = app.state.mlflow
3942
logger.info(
4043
"gateway.startup",
4144
extra={"env": settings.env, "version": settings.app_version},
4245
)
4346
await engine.startup()
47+
# Configure MLflow off the event loop; no-op + never fails when disabled.
48+
await asyncio.to_thread(tracker.start)
49+
if tracker.enabled:
50+
logger.info("mlflow.ready", extra=tracker.health())
4451
try:
4552
yield
4653
finally:
54+
await tracker.shutdown()
4755
await engine.shutdown()
4856
logger.info("gateway.shutdown")
4957

@@ -52,13 +60,17 @@ def create_app(
5260
settings: Settings | None = None,
5361
*,
5462
engine: InferenceEngine | None = None,
63+
tracker: MLflowTracker | None = None,
5564
) -> FastAPI:
5665
"""Build and return a configured :class:`FastAPI` application.
5766
5867
Args:
5968
settings: Optional settings override (primarily for tests).
6069
engine: Optional pre-built inference engine (primarily for tests, to
6170
inject a mock-backed engine). Defaults to ``InferenceEngine.build()``.
71+
tracker: Optional MLflow tracker (primarily for tests, to inject a spy or
72+
disabled tracker). Defaults to :meth:`MLflowTracker.from_settings`,
73+
which is a no-op unless ``ATLAS_MLFLOW_ENABLED`` is set.
6274
6375
Returns:
6476
A fully wired FastAPI application instance.
@@ -77,6 +89,7 @@ def create_app(
7789
)
7890
app.state.settings = settings
7991
app.state.engine = engine or InferenceEngine.build()
92+
app.state.mlflow = tracker or MLflowTracker.from_settings()
8093

8194
# Bind the resolved settings to the `get_settings` dependency so every
8295
# `Depends(get_settings)` call site (auth, health, ...) uses *this* app's

api/services/generation.py

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
"""Generation service — gateway-side adapter over the Inference Engine.
22
3-
Week 1 shipped a stub here. Week 2 makes this a thin async adapter that
4-
delegates to :class:`inference.engine.InferenceEngine`, preserving the
5-
``generate(request) -> GenerationResponse`` seam the routers depend on.
3+
Week 1 shipped a stub here. Week 2 made this a thin async adapter that delegates
4+
to :class:`inference.engine.InferenceEngine`. Week 4 adds fire-and-forget
5+
inference logging: after a response is produced (or a stream completes) the
6+
service hands an :class:`InferenceRecord` to the :class:`MLflowTracker`, which
7+
schedules the write on a background task. The tracker call never blocks or
8+
raises into the request path, so logging is invisible to latency and to
9+
clients — and a tracking outage cannot fail a generation.
610
"""
711

812
from __future__ import annotations
913

14+
import time
1015
from collections.abc import AsyncIterator
1116

1217
from fastapi import Request
1318

1419
from inference.engine import InferenceEngine
1520
from inference.types import StreamChunk
21+
from shared.context import get_request_id
1622
from shared.logging import get_logger
1723
from shared.schemas.generation import GenerationRequest, GenerationResponse
24+
from storage.mlflow import InferenceRecord, MLflowTracker
1825

1926
logger = get_logger(__name__)
2027

@@ -24,23 +31,90 @@ class GenerationService:
2431
2532
Args:
2633
engine: The process-wide inference engine (from ``app.state.engine``).
34+
tracker: The process-wide MLflow tracker (from ``app.state.mlflow``).
35+
Defaults to a disabled no-op tracker so the service stays usable
36+
without tracking configured.
2737
"""
2838

29-
def __init__(self, engine: InferenceEngine) -> None:
39+
def __init__(
40+
self, engine: InferenceEngine, tracker: MLflowTracker | None = None
41+
) -> None:
3042
self._engine = engine
43+
self._tracker = tracker or MLflowTracker.from_settings()
3144

3245
async def generate(self, request: GenerationRequest) -> GenerationResponse:
33-
"""Return a full generation for ``request``."""
34-
return await self._engine.generate(request)
46+
"""Return a full generation for ``request`` and log it (best-effort)."""
47+
start = time.perf_counter()
48+
response = await self._engine.generate(request)
49+
latency_ms = (time.perf_counter() - start) * 1000.0
50+
51+
# Fire-and-forget: returns immediately, never raises.
52+
self._tracker.log_inference(
53+
InferenceRecord(
54+
request_id=get_request_id(),
55+
model=request.model,
56+
backend=self._engine.backend_name,
57+
kind="generate",
58+
max_tokens=request.max_tokens,
59+
temperature=request.temperature,
60+
top_p=request.top_p,
61+
adapter=request.adapter,
62+
finish_reason=response.finish_reason,
63+
prompt_tokens=response.usage.prompt_tokens,
64+
completion_tokens=response.usage.completion_tokens,
65+
total_tokens=response.usage.total_tokens,
66+
latency_ms=latency_ms,
67+
prompt=request.prompt,
68+
output=response.text,
69+
)
70+
)
71+
return response
3572

3673
async def generate_stream(
3774
self, request: GenerationRequest
3875
) -> AsyncIterator[StreamChunk]:
39-
"""Yield streaming chunks for ``request``."""
76+
"""Yield streaming chunks for ``request``; log once the stream ends.
77+
78+
Accumulates the streamed text and timing (including time-to-first-token)
79+
and emits a single :class:`InferenceRecord` after the terminal chunk.
80+
"""
81+
start = time.perf_counter()
82+
ttft_ms: float | None = None
83+
parts: list[str] = []
84+
finish_reason: str | None = None
85+
4086
async for chunk in self._engine.generate_stream(request):
87+
if chunk.delta:
88+
if ttft_ms is None:
89+
ttft_ms = (time.perf_counter() - start) * 1000.0
90+
parts.append(chunk.delta)
91+
if chunk.finish_reason is not None:
92+
finish_reason = chunk.finish_reason
4193
yield chunk
4294

95+
latency_ms = (time.perf_counter() - start) * 1000.0
96+
self._tracker.log_inference(
97+
InferenceRecord(
98+
request_id=get_request_id(),
99+
model=request.model,
100+
backend=self._engine.backend_name,
101+
kind="stream",
102+
max_tokens=request.max_tokens,
103+
temperature=request.temperature,
104+
top_p=request.top_p,
105+
adapter=request.adapter,
106+
finish_reason=finish_reason,
107+
latency_ms=latency_ms,
108+
ttft_ms=ttft_ms,
109+
prompt=request.prompt,
110+
output="".join(parts),
111+
)
112+
)
113+
43114

44115
def get_generation_service(request: Request) -> GenerationService:
45-
"""FastAPI provider that wires the request's app engine into the service."""
46-
return GenerationService(engine=request.app.state.engine)
116+
"""FastAPI provider wiring the app's engine + tracker into the service."""
117+
return GenerationService(
118+
engine=request.app.state.engine,
119+
tracker=getattr(request.app.state, "mlflow", None),
120+
)

deployment/docker/Dockerfile.cpu

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,24 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
2828
# When 1 (default), also install the hf backend stack (torch + transformers).
2929
# Set to 0 for a mock-only image with no heavy ML dependencies.
3030
ARG INCLUDE_HF=1
31+
# When 1, install the MLflow tracking client (Week 4). Needed only if you run
32+
# with ATLAS_MLFLOW_ENABLED=true; the tracker imports mlflow lazily otherwise.
33+
ARG INCLUDE_MLFLOW=1
3134

3235
WORKDIR /build
3336

3437
# Copy only dependency manifests first so this layer is cached across source
3538
# edits (the expensive pip install only re-runs when requirements change).
36-
COPY requirements.txt requirements-hf.txt ./
39+
COPY requirements.txt requirements-hf.txt requirements-mlflow.txt ./
3740

3841
# Build an isolated venv that the runtime stage copies wholesale.
3942
RUN python -m venv /opt/venv
4043
ENV PATH="/opt/venv/bin:$PATH"
4144

4245
RUN pip install --upgrade pip \
4346
&& pip install -r requirements.txt \
44-
&& if [ "$INCLUDE_HF" = "1" ]; then pip install -r requirements-hf.txt; fi
47+
&& if [ "$INCLUDE_HF" = "1" ]; then pip install -r requirements-hf.txt; fi \
48+
&& if [ "$INCLUDE_MLFLOW" = "1" ]; then pip install -r requirements-mlflow.txt; fi
4549

4650
# --- Stage 2: runtime - slim, non-root ---------------------------------------
4751
FROM ${PYTHON_IMAGE} AS runtime
@@ -76,6 +80,7 @@ COPY --from=builder /opt/venv /opt/venv
7680
COPY --chown=atlas:atlas api/ ./api/
7781
COPY --chown=atlas:atlas shared/ ./shared/
7882
COPY --chown=atlas:atlas inference/ ./inference/
83+
COPY --chown=atlas:atlas storage/ ./storage/
7984

8085
USER atlas
8186
EXPOSE 8000

deployment/docker/Dockerfile.gpu

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,24 @@ ENV PATH="/opt/venv/bin:$PATH"
5151

5252
WORKDIR /app
5353

54+
# When 1, install the MLflow tracking client (Week 4). Needed only if you run
55+
# with ATLAS_MLFLOW_ENABLED=true; the tracker imports mlflow lazily otherwise.
56+
ARG INCLUDE_MLFLOW=1
57+
5458
# Dependency manifests first for layer caching (vLLM install is expensive).
55-
COPY requirements.txt requirements-inference.txt ./
59+
COPY requirements.txt requirements-inference.txt requirements-mlflow.txt ./
5660
RUN pip install --upgrade pip \
5761
&& pip install -r requirements.txt \
58-
&& pip install -r requirements-inference.txt
62+
&& pip install -r requirements-inference.txt \
63+
&& if [ "$INCLUDE_MLFLOW" = "1" ]; then pip install -r requirements-mlflow.txt; fi
5964

6065
# Run as an unprivileged user.
6166
RUN useradd --create-home --uid 10001 atlas
6267

6368
COPY --chown=atlas:atlas api/ ./api/
6469
COPY --chown=atlas:atlas shared/ ./shared/
6570
COPY --chown=atlas:atlas inference/ ./inference/
71+
COPY --chown=atlas:atlas storage/ ./storage/
6672

6773
USER atlas
6874
EXPOSE 8000

deployment/docker/docker-compose.yml

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
# AtlasAI - local orchestration (Week 3, Docker Platform).
22
#
3-
# Brings up the API Gateway + Inference Engine using the CPU image. Datastores
4-
# that arrive in later weeks (Postgres, Redis, MLflow) are scaffolded as
5-
# commented stubs below; uncomment each when its week lands.
3+
# Brings up the API Gateway + Inference Engine using the CPU image, plus the
4+
# MLflow tracking service (Week 4). Datastores that arrive in later weeks
5+
# (Postgres, Redis) remain scaffolded as commented stubs below; uncomment each
6+
# when its week lands.
67
#
78
# Usage (from this directory):
89
# docker compose up --build # mock backend
910
# ATLAS_INFERENCE_BACKEND=hf docker compose up --build # real generation
11+
# ATLAS_MLFLOW_ENABLED=true docker compose up --build # + inference tracking
1012
#
1113
# The API is then on http://localhost:8000 (/docs, /tester, /health, /ready).
14+
# The MLflow UI is on http://localhost:5001 by default (host port). Port 5000 is
15+
# avoided because macOS AirPlay Receiver binds it; override with MLFLOW_HOST_PORT.
16+
# Note: this only remaps the *host* port — inside the compose network the api
17+
# still reaches MLflow at http://mlflow:5000 (the container port is unchanged).
1218

1319
name: atlasai
1420

@@ -19,6 +25,7 @@ services:
1925
dockerfile: deployment/docker/Dockerfile.cpu
2026
args:
2127
INCLUDE_HF: "1"
28+
INCLUDE_MLFLOW: "1"
2229
image: atlasai:cpu
2330
ports:
2431
- "8000:8000"
@@ -29,6 +36,15 @@ services:
2936
ATLAS_API_KEYS: ${ATLAS_API_KEYS:-dev-key-local}
3037
ATLAS_LOG_LEVEL: ${ATLAS_LOG_LEVEL:-INFO}
3138
ATLAS_ENV: ${ATLAS_ENV:-development}
39+
# MLflow tracking (Week 4). Off by default; flip ATLAS_MLFLOW_ENABLED=true
40+
# to log inference to the bundled server below. TRACKING_URI targets the
41+
# in-network `mlflow` service (override to use an external tracking store).
42+
ATLAS_MLFLOW_ENABLED: ${ATLAS_MLFLOW_ENABLED:-false}
43+
ATLAS_MLFLOW_TRACKING_URI: ${ATLAS_MLFLOW_TRACKING_URI:-http://mlflow:5000}
44+
ATLAS_MLFLOW_EXPERIMENT: ${ATLAS_MLFLOW_EXPERIMENT:-atlasai-inference}
45+
depends_on:
46+
mlflow:
47+
condition: service_healthy
3248
# To load a full local .env instead (copy from .env.example), uncomment:
3349
# env_file:
3450
# - ../../.env
@@ -76,21 +92,33 @@ services:
7692
# timeout: 5s
7793
# retries: 5
7894

79-
# mlflow: # Week 9 - experiment / model tracking
80-
# image: ghcr.io/mlflow/mlflow:v2.16.2
81-
# command: >
82-
# mlflow server --host 0.0.0.0 --port 5000
83-
# --backend-store-uri postgresql://atlas:atlas@postgres/atlas
84-
# --artifacts-destination /mlflow/artifacts
85-
# ports:
86-
# - "5000:5000"
87-
# depends_on:
88-
# - postgres
89-
# volumes:
90-
# - mlflow-artifacts:/mlflow/artifacts
95+
mlflow: # Week 4 - experiment / model tracking
96+
image: ghcr.io/mlflow/mlflow:v2.16.2
97+
# Self-contained for Week 4: SQLite backend store (required for the model
98+
# registry) + a local artifacts dir, both on named volumes. When Postgres
99+
# lands (Week 6) point --backend-store-uri at it and drop the mlflow-db volume.
100+
command: >
101+
mlflow server --host 0.0.0.0 --port 5000
102+
--backend-store-uri sqlite:////mlflow/db/mlflow.db
103+
--artifacts-destination /mlflow/artifacts
104+
ports:
105+
# Host 5001 -> container 5000 (5000 clashes with macOS AirPlay Receiver).
106+
- "${MLFLOW_HOST_PORT:-5001}:5000"
107+
volumes:
108+
- mlflow-db:/mlflow/db
109+
- mlflow-artifacts:/mlflow/artifacts
110+
healthcheck:
111+
test: ["CMD", "python", "-c",
112+
"import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://localhost:5000/health').status==200 else sys.exit(1)"]
113+
interval: 30s
114+
timeout: 5s
115+
retries: 3
116+
start_period: 20s
117+
restart: unless-stopped
91118

92119
volumes:
93120
hf-cache:
121+
mlflow-db:
122+
mlflow-artifacts:
94123
# pg-data:
95124
# redis-data:
96-
# mlflow-artifacts:

inference/engine.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,11 @@ def ready(self) -> bool:
192192
"""Whether the engine has completed startup."""
193193
return self._ready
194194

195+
@property
196+
def backend_name(self) -> str:
197+
"""Short identifier of the active generation backend (mock|hf|vllm)."""
198+
return self._gen.name
199+
195200
def health(self) -> dict[str, object]:
196201
"""Return a JSON-serializable engine health snapshot."""
197202
return {

0 commit comments

Comments
 (0)