Skip to content

Isolate each client's state in a job - #87

Open
chrislupp wants to merge 5 commits into
developfrom
feature/jobid
Open

Isolate each client's state in a job#87
chrislupp wants to merge 5 commits into
developfrom
feature/jobid

Conversation

@chrislupp

@chrislupp chrislupp commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Closes #76.

Important

Blocked on MDO-Standards/Philote#24. The committed gRPC stubs are generated from the .proto definitions in that PR. Before this merges, #24 has to land and the proto submodule pointer here has to be bumped to the resulting commit — the pointer currently reads v0.8.0 deliberately, so that this branch never references a commit the standard repo cannot serve.

The bug

A server holds one discipline instance and shares it across every client. The protocol has each client run a setup sequence before it computes, and that sequence mutates the shared instance:

  1. Client A calls Setup; the server runs _clear_data() and rebuilds _var_meta.
  2. A calls GetVariableDefinitions and caches the metadata.
  3. Client B calls Setup; _clear_data() discards _var_meta and rebuilds it.
  4. A calls ComputeFunction; the server preallocates from B's metadata and reads A's arrays into those buffers.

If the two declared different variables, A either aborts with INTERNAL on a KeyError or — worse — has a short array read into a longer buffer and receives a zero-padded result with no error. An optimizer consumes that happily.

examples/rosenbrock.py is the sharp case, because its variable shape comes from an option, so whichever client called SetOptions last fixed the shapes both clients got. The same collision applies to SetStreamOptions and to the in-place shape edits in SetVariableShapes.

The worst instance is openmdao/group.py: OpenMdaoSubProblem is a server-side discipline holding a live om.Problem, which is not thread-safe. Concurrent clients there are not merely reading stale data.

The change

A job is a session owning one discipline instance. Servers take a factory and build one discipline per job, so state an author keeps on self — a mesh, a solver, an om.Problem — is private to one client.

Jobs are session-shaped rather than request-shaped: few of them, each alive across thousands of evaluations, each holding real resources. That is what selects instance-per-job over a stateless handler with an explicit context argument, and it is why every existing discipline hook signature is unchanged. examples/rosenbrock.py needed zero edits and its collision is gone.

The job id rides in a philote-job-id metadata header, attached by a client-side channel interceptor so no call site passes it. Clients start a job lazily on the first call that needs one, so existing scripts and both OpenMDAO components work unmodified.

Breaking change

ExplicitServer(discipline=Paraboloid())  # before: an instance
ExplicitServer(discipline=Paraboloid)    # after:  the class

The keyword is unchanged but its meaning is not, which reads as a trap. It fails safely, though: a Discipline instance is not callable, so the old call raises TypeError immediately rather than doing something subtly wrong, and the message names both forms —

discipline must be a zero-argument callable returning a Discipline, got an instance of Paraboloid. Pass the class rather than an instance of it — ExplicitServer(discipline=Paraboloid), not ExplicitServer(discipline=Paraboloid()). The server builds one discipline per job, so it needs something it can call.

A class works directly when its initialize() does its own configuration; a discipline configured externally needs a closure or functools.partial. attach_discipline() keeps its name and now takes the same callable.

How to review this

37 files, but the split is lopsided — +1223/−136 source, +1103/−266 tests, plus 85 generated lines.

Start with philote_mdo/general/job.py (new: Job, JobStore), then _resolve_job in discipline_server.py and the self._disciplinejob.discipline substitution across the three server files. The client side is one __init__ change plus start_job/end_job/keep_alive.

The test churn is two mechanical patterns, both explained in the new tests/conftest.py: 43 tests called RPC handlers with a Mock() or None context that cannot produce metadata, and ~45 configured server._discipline, which no longer exists. tests/test_jobs.py is the new coverage and is the interesting file.

Concurrency: what this actually buys

Separate jobs may evaluate at the same time, with no global lock in the path, but the GIL decides whether that yields throughput. Measured with four concurrent clients:

discipline does... 1 client 4 clients speedup
pure Python 278 ms 1107 ms 1.0×
NumPy A @ A 29 ms 138 ms 0.8× (threaded BLAS already saturates cores)
compiled solver releasing the GIL 306 ms 310 ms 4.0×

Jobs buy correctness unconditionally and throughput conditionally. Disciplines wrapping a compiled solver get real parallelism; pure-Python ones — including OpenMdaoSubProblem, whose run_model() is mostly Python — become correct under concurrent clients rather than faster. This is stated plainly in the docs so nobody expects a speedup that cannot exist.

Related: max_jobs is not the cap that binds — the gRPC thread pool is, since every in-flight RPC holds a worker for its whole duration. The server warns at startup when the pool is smaller than the job limit.

Also fixed here

Found while building this, both with regression tests:

  • Only the compute calls translated gRPC errors, so an unknown job during run_setup escaped as a raw grpc.RpcError. All base-client calls now translate, and NOT_FOUND / RESOURCE_EXHAUSTED map to distinguishable exceptions so a driver can tell "start over" from "retry later".
  • JobStore.create() reclaimed expired slots without running teardown_job(), leaking whatever the job held whenever it beat the sweeper.

Testing

359 tests pass locally on 3.12. Beyond the suite I verified: the reproduction above returns correct results for concurrent clients at dimension=2 and dimension=10; both example server/client pairs and the OpenMDAO example run with unmodified clients; a max_jobs=2 server refuses the third job and frees the slot on EndJob; an idle job is evicted and teardown_job() fires; RSS grew under a megabyte across 300 job cycles.

Not verified locally: Python 3.9. All files parse against the 3.9 grammar and there is no 3.10+ syntax, but the 3.9 interpreter here lacks scipy so the suite could not run. CI settles it.

Also not run: the Docusaurus build. docs/docs/getting-started/quickstart.md gains a Jobs section and tutorials/implicit-disciplines.md is updated for the factory, but a broken admonition would only surface in the docs workflow.

Merged from develop

Three merges from develop while this was in progress, all touching files this branch rewrote: #81#84 (the four bugs found while planning this work) and #86 (deriving DisciplineServer from the servicer base). Two conflicts, both resolved as the union of intents rather than by choosing a side — see the merge commit messages. After #86 I verified all eleven DisciplineService RPCs resolve to this package's implementations rather than to the base class's UNIMPLEMENTED stubs, since that base now supplies defaults where the old one supplied none.

A server held one discipline instance and shared it across every client.
The protocol has each client run a setup sequence before it computes, and
that sequence mutates the shared instance, so two concurrent clients
corrupted each other:

  1. A calls Setup; the server rebuilds _var_meta.
  2. A calls GetVariableDefinitions and caches the metadata.
  3. B calls Setup; _clear_data() discards _var_meta and rebuilds it.
  4. A calls ComputeFunction; the server preallocates from B's metadata.

If the two declared different variables, A either aborted with INTERNAL
on a KeyError or, worse, had a short array read into a longer buffer and
received a zero-padded result with no error. examples/rosenbrock.py was
the sharp case: its shape derives from an option, so whichever client
called SetOptions last fixed the shapes both got.

A job is a session owning one discipline instance. Servers now take a
factory and build one discipline per job, so the state an author keeps on
self stays private to one client. Every discipline hook signature is
unchanged; rosenbrock.py needed no edits and its collision is gone.

The job id rides in a philote-job-id metadata header, measured at 7.7 us
on a unary call and 13.9 us on a stream, and HPACK indexes the repeated
value so it costs a byte or two after the first call. Clients attach it
through a channel interceptor and start a job lazily, so existing scripts
and both OpenMDAO components work unmodified.

Jobs are capped (max_jobs) and evicted when idle (ttl), because a job can
hold a mesh or a live solver. Note that the gRPC thread pool, not
max_jobs, is the cap that actually binds -- every in-flight RPC holds a
worker for its whole duration -- so the server warns at startup when the
pool is smaller than the job limit.

Separate jobs may evaluate concurrently, with no global lock in the path,
but the GIL decides whether that yields throughput. With four concurrent
clients: pure Python 1.0x, NumPy A@A 0.8x (threaded BLAS already
saturates the cores), a compiled solver that releases the GIL 4.0x. Jobs
buy correctness unconditionally and throughput conditionally.

Also fixed along the way: only the compute calls translated gRPC errors,
so an unknown job during run_setup escaped as a raw grpc.RpcError; all
base-client calls now translate, and NOT_FOUND and RESOURCE_EXHAUSTED map
to distinguishable exceptions. JobStore.create() reclaimed expired slots
without running teardown_job(), leaking whatever the job held whenever it
beat the sweeper.

BREAKING CHANGE: ExplicitServer(discipline=Paraboloid()) becomes
ExplicitServer(discipline_factory=Paraboloid), and attach_discipline()
becomes attach_discipline_factory().

The proto changes live in the Philote-MDO submodule and are committed
there on feature/jobid; the submodule pointer is deliberately not moved
until that branch is published.
Brings in the four bug fixes that landed while this branch was in
progress (#81 through #84), all of which touch files the job work
rewrote.

Two conflicts, both where develop improved a line that this branch had
moved from the shared discipline onto the job. Resolved as the union of
both intents rather than by choosing a side:

  preallocate_partials (discipline_server.py)
    develop replaced the name-keyed shape dict with build_shape_index and
    the get_function_shape / get_variable_shape helpers, so that an
    implicit partial resolves against the residual rather than the output
    that shadows it (#83). Kept, reading from job.discipline.

  get_variable_definitions / get_partials_definitions (discipline_client.py)
    develop now clears the local metadata before repopulating it, so a
    repeated setup replaces rather than accumulates (#82). Kept, together
    with this branch's lazy job start and gRPC error translation. Note
    that develop's fix matters more here than it did on its own: reusing a
    client across jobs is exactly what the job work makes attractive.

Three of develop's new tests used the pre-job API and were migrated the
same way as the rest of the suite: two call preallocate_partials, which
now takes the job, and one built a server with discipline=.

No semantic conflict between #81 and the job state machine, though it was
close. This branch refuses SetOptions once a job has run Setup, and #81
moved the OpenMDAO option send out of the component constructor into
client_setup, immediately before run_setup. The orders agree.

357 tests pass. Verified after merging that #83's residual-shape fix and
the job plumbing hold together (an implicit d(res y)/dx sizes to the
residual), that the #76 reproduction still returns correct results for
concurrent clients at different dimensions, and that the examples run.
Brings in the fix for #70 (#86), which changes DisciplineServer's base
from disc.DisciplineService, the generated static-API helper class, to
disc.DisciplineServiceServicer.

No conflict, but the interaction is worth recording. The old base
provided no method bodies, so an RPC the server failed to implement was
simply absent. The servicer base supplies defaults that abort with
UNIMPLEMENTED, which means the three RPCs this branch adds -- StartJob,
EndJob and KeepAlive -- would silently answer UNIMPLEMENTED if any
subclass stopped overriding them. Verified after merging that all eleven
DisciplineService RPCs, plus two on ExplicitServer and three on
ImplicitServer, resolve to this package's implementations rather than to
the inherited stubs.

359 tests pass. Re-checked that the #76 reproduction still returns
correct results for concurrent clients at different dimensions, and that
the examples run.
@chrislupp chrislupp added the enhancement New feature or request label Aug 31, 2026
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@chrislupp chrislupp added this to the Version 0.9.0 milestone Aug 31, 2026
@chrislupp
chrislupp requested a review from jgiret August 31, 2026 02:19
codecov flagged the patch below its 95% threshold, and the gap was almost
entirely the error handling this branch adds -- which is the part that
most needs covering, since a client that mishandles an expired job runs
an optimizer against a freshly constructed discipline and returns
plausible but wrong results.

Adds coverage for:

  - all ten client calls translating NOT_FOUND into JobNotFoundError, so
    the guarantee holds across the whole surface rather than on the
    compute calls alone
  - RESOURCE_EXHAUSTED becoming JobCapacityError, which is retryable,
    against every other code staying a PhiloteServerError
  - start_job being idempotent, end_job without a job being a no-op, and
    keep_alive, none of which had a client-side test
  - a server built with no factory refusing every entry point with
    FAILED_PRECONDITION rather than an AttributeError
  - StartJob reporting capacity and a failing factory
  - EndJob's own error handlers, which are only reachable if another
    thread closes the job between _resolve_job and close. That race
    window is real, so the handlers are defensive rather than dead
  - JobStore.max_jobs, closing an unknown job, tearing down a job whose
    factory never finished, and the sweeper thread evicting on its own
    rather than being called directly

discipline_client.py, discipline_server.py, explicit_server.py,
implicit_server.py and job.py are now at 100%. 377 tests pass.
discipline_factory= was too verbose for how often it appears. The server
now takes discipline=, and attach_discipline_factory() goes back to
attach_discipline().

The keyword therefore means something different than it did before this
branch: it took an instance, and now takes a class. That reads as a trap,
but it fails safely, because a Discipline instance is not callable. The
old call raises TypeError immediately rather than doing something subtly
wrong, and the message names both forms:

    discipline must be a zero-argument callable returning a Discipline,
    got an instance of Paraboloid. Pass the class rather than an instance
    of it -- ExplicitServer(discipline=Paraboloid), not
    ExplicitServer(discipline=Paraboloid()). The server builds one
    discipline per job, so it needs something it can call.

JobStore keeps discipline_factory as its parameter name, since that is
what it actually receives and it is not the user-facing surface.

The quickstart now says "the class Paraboloid, not an instance
Paraboloid()" rather than leaving the distinction to the parameter name,
which no longer carries it.

378 tests pass, with the five files this branch touches still at 100%.
@chrislupp

Copy link
Copy Markdown
Collaborator Author

To be clear, the code should be usable/testable because the stubs were generated from the updated protocol. However, regenerating the stubs will lead to failures with potentially confusing error messages. We will need to close out the protocol PR and release it before finishing this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant