Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ python scripts/smoke_check.py

Deployment notes are in `docs/deployment.md`.

There is also a Render Blueprint in `render.yaml` for a hosted demo with one public gateway, two private workers, and a managed cache.

## Benchmarks

The scenario runner starts local gateway and worker processes, sends load, then writes JSON and markdown results.
Expand Down
12 changes: 12 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,15 @@ CACHE_ENABLED=true
This is not a production ML platform yet. It is a compact inference mesh that demonstrates training, serving, caching, routing, live worker controls, failure recovery, and benchmark evidence.

The next real hosting step is choosing a provider and wiring its private service networking. The Docker setup here is intentionally provider-neutral so the app can go to Fly.io, Render, Railway, a VM, or a Kubernetes cluster without changing the Python code.

## Render Blueprint

`render.yaml` defines a Render Blueprint for:

- one public gateway web service
- two private worker services
- one Render Key Value cache

The gateway reads `WORKER_A_HOSTPORT` and `WORKER_B_HOSTPORT`, which Render fills from each private worker service. That avoids hardcoding internal hostnames.

Important: the private worker services use Render's `starter` plan in the Blueprint because private services do not run on the free plan. Review the cost in Render before applying the Blueprint.
2 changes: 1 addition & 1 deletion gateway/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ RUN pip install --no-cache-dir -r requirements.txt

COPY gateway ./gateway
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()"
CMD ["uvicorn", "gateway.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["sh", "-c", "uvicorn gateway.app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
13 changes: 12 additions & 1 deletion gateway/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,20 @@ def env_flag(name: str, default: bool = True) -> bool:
return value.lower() not in {"0", "false", "no", "off"}


def parse_worker_hostports(env: dict[str, str] | None = None) -> list[str]:
values = env or os.environ
urls = []
for key in sorted(values):
if key.startswith("WORKER_") and key.endswith("_HOSTPORT"):
hostport = values[key].strip()
if hostport:
urls.append(f"http://{hostport}")
return urls


def parse_worker_urls(raw: str | None) -> list[str]:
if not raw:
return ["http://localhost:8011"]
return parse_worker_hostports() or ["http://localhost:8011"]
return [item.strip() for item in raw.split(",") if item.strip()]


Expand Down
55 changes: 55 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
services:
- type: keyvalue
name: modelmesh-cache
plan: free
ipAllowList: []

- type: pserv
runtime: docker
name: modelmesh-worker-a
plan: starter
dockerfilePath: ./worker/Dockerfile
dockerContext: .
envVars:
- key: WORKER_ID
value: worker-a

- type: pserv
runtime: docker
name: modelmesh-worker-b
plan: starter
dockerfilePath: ./worker/Dockerfile
dockerContext: .
envVars:
- key: WORKER_ID
value: worker-b

- type: web
runtime: docker
name: modelmesh-gateway
plan: starter
dockerfilePath: ./gateway/Dockerfile
dockerContext: .
healthCheckPath: /health
envVars:
- key: WORKER_A_HOSTPORT
fromService:
type: pserv
name: modelmesh-worker-a
property: hostport
- key: WORKER_B_HOSTPORT
fromService:
type: pserv
name: modelmesh-worker-b
property: hostport
- key: REDIS_URL
fromService:
type: keyvalue
name: modelmesh-cache
property: connectionString
- key: REQUEST_TIMEOUT_SECONDS
value: "1.5"
- key: ROUTER_STRATEGY
value: load_aware
- key: CACHE_ENABLED
value: "true"
15 changes: 15 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from gateway.app.cache import PredictionCache
from gateway.app.main import parse_worker_hostports, parse_worker_urls


def test_memory_cache_round_trip() -> None:
Expand Down Expand Up @@ -27,3 +28,17 @@ def test_disabled_cache_is_a_noop() -> None:
cache.set(key, {"prediction": {"label": "healthy"}})

assert cache.get(key) is None


def test_worker_hostport_env_vars_are_render_friendly() -> None:
env = {
"WORKER_A_HOSTPORT": "worker-a:9000",
"WORKER_B_HOSTPORT": "worker-b:9000",
"OTHER_VALUE": "ignored",
}

assert parse_worker_hostports(env) == ["http://worker-a:9000", "http://worker-b:9000"]


def test_worker_urls_still_accepts_explicit_list() -> None:
assert parse_worker_urls("http://a:9000,http://b:9000") == ["http://a:9000", "http://b:9000"]
2 changes: 1 addition & 1 deletion worker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ RUN pip install --no-cache-dir -r requirements.txt

COPY worker ./worker
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9000/health', timeout=2).read()"
CMD ["uvicorn", "worker.app.main:app", "--host", "0.0.0.0", "--port", "9000"]
CMD ["sh", "-c", "uvicorn worker.app.main:app --host 0.0.0.0 --port ${PORT:-9000}"]