Isolate each client's state in a job - #87
Open
chrislupp wants to merge 5 commits into
Open
Conversation
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.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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%.
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #76.
Important
Blocked on MDO-Standards/Philote#24. The committed gRPC stubs are generated from the
.protodefinitions in that PR. Before this merges, #24 has to land and theprotosubmodule pointer here has to be bumped to the resulting commit — the pointer currently readsv0.8.0deliberately, 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:
Setup; the server runs_clear_data()and rebuilds_var_meta.GetVariableDefinitionsand caches the metadata.Setup;_clear_data()discards_var_metaand rebuilds it.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
INTERNALon aKeyErroror — 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.pyis the sharp case, because its variable shape comes from an option, so whichever client calledSetOptionslast fixed the shapes both clients got. The same collision applies toSetStreamOptionsand to the in-place shape edits inSetVariableShapes.The worst instance is
openmdao/group.py:OpenMdaoSubProblemis a server-side discipline holding a liveom.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, anom.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.pyneeded zero edits and its collision is gone.The job id rides in a
philote-job-idmetadata 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
The keyword is unchanged but its meaning is not, which reads as a trap. It fails safely, though: a
Disciplineinstance is not callable, so the old call raisesTypeErrorimmediately rather than doing something subtly wrong, and the message names both forms —A class works directly when its
initialize()does its own configuration; a discipline configured externally needs a closure orfunctools.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_jobindiscipline_server.pyand theself._discipline→job.disciplinesubstitution across the three server files. The client side is one__init__change plusstart_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 aMock()orNonecontext that cannot produce metadata, and ~45 configuredserver._discipline, which no longer exists.tests/test_jobs.pyis 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:
A @ AJobs buy correctness unconditionally and throughput conditionally. Disciplines wrapping a compiled solver get real parallelism; pure-Python ones — including
OpenMdaoSubProblem, whoserun_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_jobsis 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:
run_setupescaped as a rawgrpc.RpcError. All base-client calls now translate, andNOT_FOUND/RESOURCE_EXHAUSTEDmap to distinguishable exceptions so a driver can tell "start over" from "retry later".JobStore.create()reclaimed expired slots without runningteardown_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=2anddimension=10; both example server/client pairs and the OpenMDAO example run with unmodified clients; amax_jobs=2server refuses the third job and frees the slot onEndJob; an idle job is evicted andteardown_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.mdgains a Jobs section andtutorials/implicit-disciplines.mdis updated for the factory, but a broken admonition would only surface in the docs workflow.Merged from develop
Three merges from
developwhile this was in progress, all touching files this branch rewrote: #81–#84 (the four bugs found while planning this work) and #86 (derivingDisciplineServerfrom 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 elevenDisciplineServiceRPCs resolve to this package's implementations rather than to the base class'sUNIMPLEMENTEDstubs, since that base now supplies defaults where the old one supplied none.