Skip to content

Standalone host: serve without Ray (python -m tabctx.serve) - #83

Merged
amrit110 merged 3 commits into
VectorInstitute:mainfrom
mgsalem:standalone-serve
Nov 1, 2026
Merged

amrit110 merged 3 commits into
VectorInstitute:mainfrom
mgsalem:standalone-serve

Conversation

@mgsalem

@mgsalem mgsalem commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

inference-platform is moving its LLM serving to Dynamo, which cannot host tabctx (VectorInstitute/inference-platform#192). This adds a Ray-free host for the same service, so tabctx can run as a plain container behind a gateway. The Ray deployment path is unchanged.

What changed:

  • serve/service.py: the service, moved out of app.py verbatim; importable without ray. New: /livez, close(), and a ROUTE_SPECS snapshot of the registered routes.
  • serve/app.py: same tabctx.serve.app:app import path, now only the Ray host (deployment settings and the affinity router).
  • serve/standalone.py: python -m tabctx.serve serves the same routes under uvicorn. New standalone extra, [serve] minus ray. Single replica only, since the affinity router is Ray's.
  • GET /livez is check_health as HTTP for liveness probes: 503 only on a dead per-GPU worker. /readyz stays the readiness probe (it also 503s on transient pressure, which must not restart the process).
  • A small limiter replicates max_ongoing_requests/max_queued_requests shedding (TABCTX_MAX_ONGOING_REQUESTS/TABCTX_MAX_QUEUED_REQUESTS, defaults 8/16; probes exempt).
  • Dockerfile + docker_image.yml: a serving image (python:3.12-slim, pinned torch, tabctx[standalone,tabicl,tabpfn], non-root, weights mounted not baked), pushed to ghcr.io/vectorinstitute/tabctx:<version> on release tags, built and smoke-tested on PRs. Another registry is a few lines if preferred.

One subtlety for review: serve.ingress rewrites each route's self into a Depends by mutating the handler functions' __signature__, then freezes the app with cloudpickle. Once the handlers became importable in pristine form from service.py, cloudpickle shipped them by reference and replicas lost the rewrite (422 demanding self as a query parameter on every route; the integration tests caught it). app.py now registers the service module for by-value pickling, the semantics the old single-module layout had implicitly.

Tested: unit suite in a ray-less environment (602 passed; new standalone tests cover fit/predict inline and multipart, tenancy, 404/422, the limiter, livez, and a fresh-interpreter no-ray import check), integration suite on a live two-replica Ray Serve cluster (42 passed, ray 2.59.0), a real uvicorn boot exercised over a socket, and the image workflow's fake-backend smoke test in CI. Not validated on a GPU; that stays with the deploying platform's rehearsal.

@codecov-commenter

codecov-commenter commented Oct 30, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.15275% with 122 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.74%. Comparing base (0306e2c) to head (5c61ed4).

Files with missing lines Patch % Lines
src/tabctx/serve/service.py 72.48% 115 Missing ⚠️
src/tabctx/serve/standalone.py 93.44% 4 Missing ⚠️
src/tabctx/serve/__main__.py 0.00% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #83      +/-   ##
==========================================
+ Coverage   84.78%   88.74%   +3.95%     
==========================================
  Files          56       59       +3     
  Lines        5021     5108      +87     
==========================================
+ Hits         4257     4533     +276     
+ Misses        764      575     -189     
Flag Coverage Δ
integration 36.88% <30.95%> (-0.35%) ⬇️
unit 88.44% <73.93%> (+6.57%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/tabctx/serve/app.py 100.00% <100.00%> (+65.59%) ⬆️
src/tabctx/serve/workers.py 83.38% <100.00%> (+12.97%) ⬆️
src/tabctx/serve/__main__.py 0.00% <0.00%> (ø)
src/tabctx/serve/standalone.py 93.44% <93.44%> (ø)
src/tabctx/serve/service.py 72.48% <72.48%> (ø)

... and 1 file with indirect coverage changes

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The service moves to serve/service.py, host-agnostic and importable
without ray; serve/app.py keeps its import path and is now only the Ray
host (the deployment settings and the consistent-hash affinity router).
serve/standalone.py serves the same routes from one plain uvicorn
process for platforms that don't run Ray, with a /livez liveness route
(503 only on a dead per-GPU worker; /readyz also 503s on transient
pressure, which must not restart a replica) and a request limiter
replicating max_ongoing_requests/max_queued_requests shedding. New
'standalone' extra ([serve] minus ray). A Dockerfile and
docker_image.yml build and smoke-test a serving image on release tags.

One subtlety: serve.ingress rewrites each route's 'self' into a Depends
on the handler functions' own __signature__, then freezes the app with
cloudpickle. With the handlers now importable in pristine form from
serve/service.py, cloudpickle shipped them by reference and replicas
lost the rewrite (a 422 demanding 'self' as a query parameter on every
route, caught by the integration suite). app.py registers the service
module for by-value pickling, the semantics the old single-module
layout had implicitly.
@amrit110

Copy link
Copy Markdown
Member

Automated first-pass review by Claude (AI), not a human review. @amrit110 will follow up. Reviewed against how inference-platform#204 runs this server (python -m tabctx.serve, one replica, 4 GPUs, 32 ongoing / 64 queued, probes on /readyz and /livez). Only issues reproduced by running the server are listed.

1. Under #204's limits, /readyz returns 503 on a healthy pod, which takes the only replica out of the LoadBalancer.
/readyz asks each GPU worker for report() in turn (30 s per worker), but report() waits in the same 16-thread pool as fit and predict, and fits on one device run one at a time (_fit_lock). Once more than 16 calls are waiting on one worker, report() can't start.
Reproduced: the real standalone server with worker processes, the fake backend with a 20 s fit, and 36 concurrent fits.

  • /readyz returned 503 after 30 s, three times in a row.
  • /livez stayed 200 throughout.

#204's probe times out at 10 s, and three failures (about 90 s) remove the endpoint, so readiness would flap under load. Options: serve /readyz from a cached snapshot, give report() its own thread, or point readiness at something cheap.
(src/tabctx/serve/service.py:880-882, src/tabctx/serve/workers.py:76,136,271-292)

2. The image has no C compiler, so TabICL's torch.compile silently falls back to eager kernels.
TABCTX_COMPILE defaults to 1, and Triton 3.5.1 builds a small C module at runtime, raising "Failed to find C compiler" without gcc or clang. python:3.12-slim-bookworm has neither. With suppress_errors: True this only warns, and runs at about 1.3x compiled time by the repo's own measurement. CI misses it because the smoke test uses the fake backend. Fix: install gcc in the image, or set TABCTX_COMPILE=0 deliberately. (Dockerfile:24)

3. Only torch is pinned in the image. uv pip install "torch==2.9.1" ".[standalone,tabicl,tabpfn]" ignores uv.lock. tabpfn is unbounded, tabicl is >=2.2.0, and fastapi and uvicorn are unbounded. A PR smoke build and the release build can therefore ship different model libraries, and a tabpfn change in checkpoint layout would fail at startup under HF_HUB_OFFLINE=1. Fix: install from the lockfile (uv export --frozen then uv pip install -r). (Dockerfile:41-43)

4. The branch doesn't merge cleanly into main (.dockerignore, CHANGELOG.md, uv.lock). Taking this branch's .dockerignore as-is would drop examples/agent_demo/ from that image's build context, so the two need combining.

Verified matching #204's contract:

  • the command and its __main__ guard
  • every env var #204 sets (names, parsing, first backend as the default)
  • RAY_SERVE_SESSION_ID_HEADER_KEY read per request, without Ray
  • /livez returning 503 only for a dead worker
  • the port binding only after all GPU workers are built (startup shows as connection refused)
  • limiter enforcement with health routes exempt and no leak on client disconnect
  • SIGTERM draining in-flight requests
  • a non-root image

Unit tests: 602 passed, 12 skipped.

# Conflicts:
#	.dockerignore
#	CHANGELOG.md
#	uv.lock
- Workers answer /readyz's report on their own thread. It waited behind
  fits in the shared 16-thread pool, so more than 16 calls queued on one
  device failed readiness on a healthy replica.
- The image installs gcc, so torch.compile can build Triton's C helper
  instead of silently running eager kernels.
- The image installs every dependency at its uv.lock version, except
  torch's own stack, which keeps the image's torch pin. CI checks both,
  and builds the image when pyproject.toml or uv.lock change.
@amrit110
amrit110 merged commit f558c30 into VectorInstitute:main Nov 1, 2026
4 checks passed
@amrit110 amrit110 mentioned this pull request Nov 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants