Skip to content

Commit 900f418

Browse files
authored
feat: add model export endpoint for seed-based cold start reduction (#34)
* feat: add model export endpoint for seed-based cold start reduction Signed-off-by: Madhu Goutham Reddy Ambati <mambati@redhat.com> * fix: address review sync handler, shared helper, integration test Signed-off-by: Madhu Goutham Reddy Ambati <mambati@redhat.com> * fix: set mtime on tar entries to preserve sync freshness Signed-off-by: Madhu Goutham Reddy Ambati <mambati@redhat.com> * fix: exclude gated models from seed export Signed-off-by: Madhu Goutham Reddy Ambati <mambati@redhat.com> * style: apply ruff format to pass CI lint Signed-off-by: Madhu Goutham Reddy Ambati <mambati@redhat.com> * chore: bump VERSION for seed export feature Signed-off-by: Madhu Goutham Reddy Ambati <mambati@redhat.com> --------- Signed-off-by: Madhu Goutham Reddy Ambati <mambati@redhat.com>
1 parent 720d12e commit 900f418

3 files changed

Lines changed: 116 additions & 26 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.0.1
1+
0.0.2

tests/test_dual_server_client.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
import io
15+
import json
1416
import os
1517
import random
18+
import tarfile
1619
import time
1720

1821
import pytest
@@ -2034,6 +2037,43 @@ def test_training_server_flush_error_handling():
20342037
print("✓ Flush error handling tests passed!")
20352038

20362039

2040+
def test_model_export():
2041+
"""Test GET /model/export returns a valid tar.gz with model files and metadata."""
2042+
print("Testing model export endpoint...")
2043+
2044+
r = requests.get(f"{TRAINING_URL}/model/export", timeout=30)
2045+
assert r.status_code == 200, f"Expected 200, got {r.status_code}"
2046+
assert r.headers["content-type"] == "application/gzip"
2047+
2048+
buf = io.BytesIO(r.content)
2049+
with tarfile.open(fileobj=buf, mode="r:gz") as tar:
2050+
names = tar.getnames()
2051+
print(f" Archive contains: {names}")
2052+
2053+
assert "metadata.json" in names, "metadata.json missing from archive"
2054+
2055+
joblib_files = [n for n in names if n.endswith(".joblib")]
2056+
assert len(joblib_files) > 0, "No .joblib model files in archive"
2057+
2058+
gated_files = [n for n in names if "gated" in n]
2059+
assert not gated_files, (
2060+
f"Gated models must not be in seed export (seed load path never uses them): {gated_files}"
2061+
)
2062+
2063+
meta_member = tar.getmember("metadata.json")
2064+
meta_file = tar.extractfile(meta_member)
2065+
meta = json.loads(meta_file.read())
2066+
assert "model_type" in meta, "model_type missing from metadata"
2067+
assert "quantile_alpha" in meta, "quantile_alpha missing from metadata"
2068+
assert "exported_at" in meta, "exported_at missing from metadata"
2069+
print(f" Metadata: model_type={meta['model_type']}, samples={meta.get('ttft_samples', 'n/a')}")
2070+
2071+
for member in tar.getmembers():
2072+
assert member.mtime > 0, f"{member.name} has mtime=0 (would break sync freshness checks)"
2073+
2074+
print(f"✓ Model export passed: {len(joblib_files)} model files + metadata")
2075+
2076+
20372077
if __name__ == "__main__":
20382078
print("Running dual-server architecture tests with prefix cache score support...")
20392079
print(f"Prediction server: {PREDICTION_URL}")
@@ -2072,6 +2112,7 @@ def test_training_server_flush_error_handling():
20722112
("XGBoost Trees", test_model_specific_endpoints_on_training_server),
20732113
("Flush API", test_training_server_flush_api),
20742114
("Flush Error Handling", test_training_server_flush_error_handling),
2115+
("Model Export", test_model_export),
20752116
("Dual Server Model Learns Equation", test_dual_server_quantile_regression_learns_distribution),
20762117
("End-to-End Workflow", test_end_to_end_workflow),
20772118
]

training/training_server.py

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
import io
1415
import json
1516
import logging
1617
import os # Added this import
18+
import random
19+
import tarfile
1720
import threading
1821
import time
1922
from collections import deque
@@ -24,7 +27,7 @@
2427
import pandas as pd
2528
import uvicorn
2629
from fastapi import FastAPI, HTTPException, status
27-
from fastapi.responses import FileResponse, JSONResponse, Response
30+
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
2831
from pydantic import BaseModel, Field
2932
from scipy.stats import norm
3033
from sklearn.linear_model import BayesianRidge
@@ -184,6 +187,18 @@ def quantile_violation_rate(y_true, y_pred, quantile):
184187
return violation_rate * 100
185188

186189

190+
def _get_model_paths():
191+
"""Canonical mapping of model names to file paths."""
192+
return {
193+
"ttft": settings.TTFT_MODEL_PATH,
194+
"tpot": settings.TPOT_MODEL_PATH,
195+
"ttft_scaler": settings.TTFT_SCALER_PATH,
196+
"tpot_scaler": settings.TPOT_SCALER_PATH,
197+
"ttft_gated": settings.TTFT_GATED_MODEL_PATH,
198+
"tpot_gated": settings.TPOT_GATED_MODEL_PATH,
199+
}
200+
201+
187202
class LatencyPredictor:
188203
"""
189204
Manages model training, prediction, and data handling.
@@ -1438,6 +1453,14 @@ def load_models(self):
14381453
self.ttft_model = joblib.load(settings.TTFT_MODEL_PATH)
14391454
if self.model_type == ModelType.BAYESIAN_RIDGE and os.path.exists(settings.TTFT_SCALER_PATH):
14401455
self.ttft_scaler = joblib.load(settings.TTFT_SCALER_PATH)
1456+
meta_path = os.path.join(os.path.dirname(settings.TTFT_MODEL_PATH), "metadata.json")
1457+
if os.path.exists(meta_path):
1458+
try:
1459+
with open(meta_path) as f:
1460+
seed_meta = json.load(f)
1461+
logging.info("Loaded seed model: %s", seed_meta)
1462+
except Exception:
1463+
logging.warning("Failed to read seed metadata from %s", meta_path)
14411464
else:
14421465
result = self._create_default_model("ttft")
14431466
if self.model_type == ModelType.BAYESIAN_RIDGE:
@@ -1846,6 +1869,53 @@ async def readiness_check():
18461869
return {"status": "ready"}
18471870

18481871

1872+
@app.get("/model/export")
1873+
def export_models():
1874+
"""Bundle trained models and metadata for seeding new deployments."""
1875+
if not predictor.is_ready:
1876+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Models are not ready.")
1877+
1878+
# Exclude gated ensemble models from seed bundles: load_models() never
1879+
# loads them, but the prediction server syncs them on file existence and
1880+
# would then dispatch all traffic through the source deployment's frozen
1881+
# gate, masking local base-model retraining until ensemble_active flips.
1882+
paths = {name: path for name, path in _get_model_paths().items() if name not in ("ttft_gated", "tpot_gated")}
1883+
1884+
with predictor.lock:
1885+
snapshots = {}
1886+
for name, p in paths.items():
1887+
if os.path.exists(p):
1888+
with open(p, "rb") as f:
1889+
snapshots[name] = f.read()
1890+
meta = {
1891+
"model_type": predictor.model_type.value,
1892+
"quantile_alpha": settings.QUANTILE_ALPHA,
1893+
"exported_at": datetime.now(UTC).isoformat(),
1894+
"ttft_samples": sum(len(d) for d in predictor.ttft_data_buckets.values()),
1895+
"tpot_samples": sum(len(d) for d in predictor.tpot_data_buckets.values()),
1896+
}
1897+
1898+
buf = io.BytesIO()
1899+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
1900+
for name, data in snapshots.items():
1901+
info = tarfile.TarInfo(name=os.path.basename(paths[name]))
1902+
info.size = len(data)
1903+
info.mtime = int(time.time())
1904+
tar.addfile(info, io.BytesIO(data))
1905+
meta_bytes = json.dumps(meta, indent=2).encode()
1906+
meta_info = tarfile.TarInfo(name="metadata.json")
1907+
meta_info.size = len(meta_bytes)
1908+
meta_info.mtime = int(time.time())
1909+
tar.addfile(meta_info, io.BytesIO(meta_bytes))
1910+
1911+
buf.seek(0)
1912+
return StreamingResponse(
1913+
buf,
1914+
media_type="application/gzip",
1915+
headers={"Content-Disposition": "attachment; filename=seed-model.tar.gz"},
1916+
)
1917+
1918+
18491919
@app.get("/metrics", status_code=status.HTTP_200_OK)
18501920
async def metrics():
18511921
"""Prometheus metrics including coefficients/importances, bucket counts, and quantile-specific metrics."""
@@ -2078,14 +2148,7 @@ async def tpot_xgb_json():
20782148
@app.get("/model/{model_name}/info")
20792149
async def model_info(model_name: str):
20802150
"""Get model file information including last modified time."""
2081-
model_paths = {
2082-
"ttft": settings.TTFT_MODEL_PATH,
2083-
"tpot": settings.TPOT_MODEL_PATH,
2084-
"ttft_scaler": settings.TTFT_SCALER_PATH,
2085-
"tpot_scaler": settings.TPOT_SCALER_PATH,
2086-
"ttft_gated": settings.TTFT_GATED_MODEL_PATH,
2087-
"tpot_gated": settings.TPOT_GATED_MODEL_PATH,
2088-
}
2151+
model_paths = _get_model_paths()
20892152

20902153
if model_name not in model_paths:
20912154
raise HTTPException(status_code=404, detail=f"Unknown model: {model_name}")
@@ -2113,14 +2176,7 @@ async def model_info(model_name: str):
21132176
@app.get("/model/{model_name}/download")
21142177
async def download_model(model_name: str):
21152178
"""Download a model file."""
2116-
model_paths = {
2117-
"ttft": settings.TTFT_MODEL_PATH,
2118-
"tpot": settings.TPOT_MODEL_PATH,
2119-
"ttft_scaler": settings.TTFT_SCALER_PATH,
2120-
"tpot_scaler": settings.TPOT_SCALER_PATH,
2121-
"ttft_gated": settings.TTFT_GATED_MODEL_PATH,
2122-
"tpot_gated": settings.TPOT_GATED_MODEL_PATH,
2123-
}
2179+
model_paths = _get_model_paths()
21242180

21252181
if model_name not in model_paths:
21262182
raise HTTPException(status_code=404, detail=f"Unknown model: {model_name}")
@@ -2139,14 +2195,7 @@ async def download_model(model_name: str):
21392195
async def list_models():
21402196
"""List all available models with their status."""
21412197
models = {}
2142-
model_paths = {
2143-
"ttft": settings.TTFT_MODEL_PATH,
2144-
"tpot": settings.TPOT_MODEL_PATH,
2145-
"ttft_scaler": settings.TTFT_SCALER_PATH,
2146-
"tpot_scaler": settings.TPOT_SCALER_PATH,
2147-
"ttft_gated": settings.TTFT_GATED_MODEL_PATH,
2148-
"tpot_gated": settings.TPOT_GATED_MODEL_PATH,
2149-
}
2198+
model_paths = _get_model_paths()
21502199

21512200
for model_name, model_path in model_paths.items():
21522201
if os.path.exists(model_path):

0 commit comments

Comments
 (0)