From cb604487f35c9f162869a45e23a7892b17891cb4 Mon Sep 17 00:00:00 2001 From: Prakhar Verma Date: Sun, 10 May 2026 13:23:37 -0400 Subject: [PATCH 1/2] Support provider worker hostports --- gateway/Dockerfile | 2 +- gateway/app/main.py | 13 ++++++++++++- tests/test_cache.py | 15 +++++++++++++++ worker/Dockerfile | 2 +- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/gateway/Dockerfile b/gateway/Dockerfile index f553694..d051e41 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -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}"] diff --git a/gateway/app/main.py b/gateway/app/main.py index 53d98f8..5954fd6 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -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()] diff --git a/tests/test_cache.py b/tests/test_cache.py index 210cb27..eaf38b3 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -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: @@ -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"] diff --git a/worker/Dockerfile b/worker/Dockerfile index 9a1dbca..5c03c31 100644 --- a/worker/Dockerfile +++ b/worker/Dockerfile @@ -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}"] From db25ea0617fd00920ce594bd7501c194e020142e Mon Sep 17 00:00:00 2001 From: Prakhar Verma Date: Sun, 10 May 2026 13:23:43 -0400 Subject: [PATCH 2/2] Add Render Blueprint --- README.md | 2 ++ docs/deployment.md | 12 ++++++++++ render.yaml | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 render.yaml diff --git a/README.md b/README.md index c5f2513..679ac53 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/deployment.md b/docs/deployment.md index 3f70c32..57abe8e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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. diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..b6ee836 --- /dev/null +++ b/render.yaml @@ -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"