fix(core): serialize writers on the runtime snapshot - #41
Open
terylt wants to merge 1 commit into
Open
Conversation
…e reloads. Signed-off-by: Teryl Taylor <terylt@ibm.com>
araujof
requested changes
Aug 26, 2026
araujof
left a comment
Member
There was a problem hiding this comment.
Nice work! Please address the following findings, and we should be good to go.
load_configholdsruntime_writewhile calling host-suppliedPluginFactory::create()code. A factory that re-enters the engine throughregister_handler,annotate_route, orunregisterwill try to acquire the same mutex and deadlock. Please create the plugin instances before taking this lock, then lock only while updating and publishing the latest snapshot.
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: #23
Description
PolicyEnginepublishes its runtime state as anArcSwap<RuntimeSnapshot>, and every mutation is a read-modify-write: load the current snapshot, clone it, apply the change, store the clone. Nothing serialised the writers. Two threads that loaded the same snapshot each cloned it, each applied only their own change, and whichever stored last silently discarded the other's work. The call that lost still returnedOk, and the generation counter still bumped for both, so no downstream cache saw any sign that a registration had gone missing.The affected paths are
mutate_runtime,try_mutate_runtime, and the inline snapshot swap inload_config, which cannot use either helper because it has to swap the registry, the executor, and the cache cap together as one snapshot.The issue was filed from reading the code and noted that it had not been reproduced. It reproduces. Sixteen threads calling
register_handlerat the same instant left one plugin registered out of sixteen, with all sixteen calls returningOk:The existing
test_manager_arc_shareable_with_concurrent_dispatch_and_registrationdid not catch this because it runs oncurrent_thread, so it can never interleave a load with a store.Fix
A
runtime_write: Mutex<()>onPolicyEngine, held across the copy-on-write in all three paths. Readers never take it, so the invoke path is byte-for-byte what it was and stays lock-free. It guards()rather than data because the data lives in theArcSwap; the lock exists only to make the read-modify-write atomic with respect to other writers.Poisoning is ignored via the
PoisonError::into_innerconvention already used for the other locks in this file. A panic inside a mutation closure leaves theArcSwapholding whatever was last published, which is a complete snapshot either way, so there is no half-applied state for the next writer to inherit.Why a mutex rather than
arc_swap::rcuThe issue suggested
rcu()as the smaller change. Two things pushed the other way.rcure-runs its closure on contention, soFnOncewould have to becomeFnMut, and every call site currently moves its inputs into the closure. Each would need per-attempt cloning of the plugin, config, and handler.More decisive:
load_configcannot usercuat all in practice. Its critical section callsinstantiate_plugins_into, which runs the registered plugin factories. Retrying means constructing plugins two or three times, which is side-effecting and potentially expensive. That path would need a mutex regardless, leaving two different mechanisms in one file when the issue explicitly asks forload_configto be covered by the same one.The cost of the mutex is that config loads and registrations serialize against each other. These are cold paths, and the issue lists that serialisation as a requirement rather than a regression.
Acceptance criteria
mutate_runtimecalls cannot lose a mutationtry_mutate_runtimegets the same treatment, including that a failed mutation publishes nothing. The?drops the guard on the way out having stored nothing, so a rejected mutation publishes nothing and blocks no oneload_config's inline snapshot swap is covered by the same mechanism. The guard is taken before the factories lock so every mutation path acquires in the same order, and dropped before the routing cache is cleared, matching the other pathsmaininvokebodies. A hook invocation still does oneArcSwap::load_fulland owns that snapshot for the rest of the call, so it runs the same instructions it did before. Not measured, see belowTests
concurrent_registration_loses_no_pluginsputs sixteen real OS threads throughregister_handler, lined up on astd::sync::Barrierso they all load the same snapshot. Without the barrier the loss is occasional; with it, it is reliable. It asserts both that all sixteen plugins are present and that the generation advanced exactly sixteen times. Real threads rather thantokio::spawn, for the reason the existing test misses the bug.failed_registration_publishes_nothingregisters a duplicate name, asserts the error, and asserts the generation did not move, so a cache keyed on the generation does not evict and rebuild over a registration that never happened.Verified red against the unfixed code before the fix went in, then green.
Deadlock analysis
Nothing under the lock re-enters a mutating method. The closures are confined to
registry.register,HashMapinsert and remove, andregistry.unregister.The one path worth checking is config visitors, which can install handlers via
annotate_route. They run at the end ofload_config_yaml, afterload_confighas returned and released the guard, not during it. Thepraxis-policy-apl-runtimevisitor tests exercise that path and pass.Not covered
annotate_route,remove_route_annotation, andunregistershare the same lock throughmutate_runtime, so they are correct by construction, but there is no test driving them from many threads at once. Only the registration path is exercised concurrently. A mixed-operation concurrency test would be a reasonable follow-up.The claim that invoke latency is unaffected is reasoning, not a measurement. The workspace has no benchmarks, so it rests on the lock being absent from the read path rather than on a before and after number. An uncontended lock that is never taken costs nothing, but nobody timed it.
What does change is that writers now serialise against each other. Two simultaneous
load_configcalls, or a registration arriving during a config load, will queue. Both are cold paths, and that serialisation is what the issue asked for.No live host was involved. The measurements above come from the unit test, not from a proxy under load.
Compatibility
No public API change and no config change. Nothing in the workspace registers or loads config concurrently today, so no shipped deployment was hitting this. It matters because registration takes
&selfon an engine designed to sit behind anArc,register_rawis documented for out-of-process bridge hosts, and hot reload would walk straight into it.Verification
make ci(fmt, clippy with-D warnings, and both test passes) exits 0--all-featuresmake lint-extrafails, but onmainas well and in files this branch does not touch. It is a spellcheck hittingunparseableandoverrideableacross nineteen files, and it is not part ofmake ci