fix(server): rebuild client-aware AI filter factories on config reload - #723
fix(server): rebuild client-aware AI filter factories on config reload#723Artemon-line wants to merge 1 commit into
Conversation
|
Unsigned commits: 5c08ac5. Please sign your commits. |
5c08ac5 to
7534d98
Compare
|
AI tool authorship detected:
Sorry, this project does not accept commits authored by tools as valid. |
Client-aware filter factories (openai_file_resolve, openai_web_search, anthropic_web_search, and the compact/file_search callout filters) captured a clone of the startup SubRequestClient. On a hot config reload, reload_pipelines built an updated client carrying the new body_limits.max_response_bytes ceiling and threaded it into pipeline resolution, but the registry factory closures still handed the frozen startup client to every newly built filter. The reload looked successful while the new callout response ceiling never reached the client-aware AI filters. Introduce ReloadableSubRequestClient, an Arc<ArcSwap<SubRequestClient>> handle. Factories capture a clone of the handle and read the current client via current() at filter-build time, so filters rebuilt during a reload observe the swapped-in ceiling. reload_pipelines stores the new client before resolve_pipelines and rolls the exact prior Arc back on failure, leaving live pipelines and their client unchanged. Tests: - Unit tests for the handle (store/load/shared-cell via Arc::ptr_eq). - Reload unit tests asserting the handle swaps on success and is restored on a failed reload. - Functional integration test (reload_response_ceiling) driving a real reload through the server bootstrap + file watcher: tightening the ceiling turns a previously-forwarded oversized file callout into a 413, and relaxing it admits a previously-rejected file. Closes praxis-proxy#639 Signed-off-by: Artemy <ahladenk@redhat.com>
5526a61 to
70a858c
Compare
praxis-bot
left a comment
There was a problem hiding this comment.
Sound fix. The ReloadableSubRequestClient wrapper follows the established Arc<ArcSwap<_>> reload idiom, the store-before-build ordering in the reload path is correct (factories read from the handle during resolve_pipelines, so the swap must precede the build), and the rollback on failure restores the exact prior Arc so live pipelines and their client are unaffected. The integration tests are well-designed -- testing both tightening and relaxing directions with a Files API stub that isolates the ceiling check to the content callout. Two medium convention items noted below.
| use super::*; | ||
|
|
||
| /// Minimal client for handle tests. | ||
| fn client() -> SubRequestClient { |
There was a problem hiding this comment.
[Medium] Per project convention, inside mod tests the ordering is: imports, test functions, then test utilities (with a full-width // Test Utilities separator). Move client() below the three #[test] functions.
Also, the inline comment at line 123 (// Smoke check: ...) should be removed or converted to a tracing::debug! call -- project convention prohibits inline comments in test function bodies.
| json_post("/v1/responses", &body) | ||
| } | ||
|
|
||
| /// Tightening the ceiling on reload must reject the content callout that the |
There was a problem hiding this comment.
[Medium] Two convention items across the new test code:
-
Doc comments on test functions:
tightening_ceiling_on_reload_rejects_oversized_file(here, lines 112-114) andrelaxing_ceiling_on_reload_admits_previously_rejected_file(lines 150-151) have///doc comments. Per convention: "Do not add doc comments on test functions. The function name is the documentation." Remove them. Same applies tovalid_reload_swaps_client_in_handle(line 702) andfailed_reload_restores_client_in_handle(line 729) inserver/src/reload.rs. -
Inline comments in test bodies: Lines 123, 131-132, 160, and 168-169 use
//comments to narrate test steps. Per convention, convert these totracing::debug!calls or fold the explanation into assertion messages.
Motivation
AI filters that use the shared
SubRequestClientdid not receive an updated response-body ceiling after a dynamic config reload.At startup,
register_ai_filtersregisters the client-aware factories (openai_file_resolve,openai_web_search,anthropic_web_search, and the compact / file-search callout filters), each capturing a clone of the startupSubRequestClient. On reload,reload_pipelinescorrectly builds anupdated_clientfrom the newbody_limits.max_response_bytesand threads it into pipeline resolution — but the already-registered factory closures keep handing the frozen startup client to every filter they build. The reload looks successful while the new callout response ceiling never reaches the client-aware AI filters.Closes #639.
Approach
Introduce
ReloadableSubRequestClient, a sharedArc<ArcSwap<SubRequestClient>>handle that mirrors theArc<ArcSwap<_>>reload idiom already used infilters/src/routing/.filters/src/subrequest.rs(new): the handle.new,load()(sharedArc, no client clone — used for rollback pointer identity),current()(owned snapshot at the construction boundary that needs ownership),store().filters/src/register.rs): each client-aware factory now captures a clone of the handle (a cheapArcbump) and callshandle.current()each time it builds a filter, so a filter rebuilt during reload observes the swapped-in client.server/src/reload.rs):reload_pipelinesstores the new client into the handle beforeresolve_pipelines, so rebuilt filters see the new ceiling. On a build failure it restores the exact priorArc, leaving live pipelines and their client unchanged.server/src/server.rs,watcher.rs,commands.rs,lib.rs): the handle is created once at startup and threaded through the watcher into the reload path;--validate/--dumpbuild through the same handle.This covers every filter registered via
from_config_with_client, not justopenai_file_resolve/openai_web_search.Testing
filters/src/subrequest.rs): handlestore/load/shared-cell behavior, asserted viaArc::ptr_eq(the ceiling field is private, so pointer identity is the observable).server/src/reload.rs): a valid reload swaps the client in the handle (!ptr_eq); a failed reload (bad filter name) restores the original client (ptr_eq).tests/integration/tests/suite/reload_response_ceiling.rs): drives a real reload through the server bootstrap + file watcher viastart_reloadable_proxy. A Files API stub serves small metadata but a 4 KiB content body, so a mid-range ceiling passes the metadata callout while rejecting the content callout.tightening_ceiling_on_reload_rejects_oversized_file: start with a large ceiling → probe returns200; reload with a small ceiling → probe returns413with thefile_resolve_errorenvelope. Without the fix this stays200(the rebuilt filter keeps the frozen startup client), so the test has teeth.relaxing_ceiling_on_reload_admits_previously_rejected_file: the reverse direction, proving the swap is not one-way.make lintpasses (clippy-D warnings,fmt --check, and allcargo xtaskdoc/coverage checks); server unit tests and the two new functional tests are green.Open questions
Debugging the bug with LLDB
Create a config in a quiet directory (not
/tmp— the watcher watches the config's parent directory and reacts to any file change in it, so/tmpactivity retriggers reloads):No HTTP request is needed — starting the proxy and editing the config file is enough, because
resolve_pipelinesrebuilds every filter from the registry factories on each reload.Before (on
main, without this fix)The factory captures a frozen clone of the startup client, so
clientat the factory line is a plainSubRequestClientwhose ceiling never changes:After (with this fix)
The factory now reads a swappable handle (
client.current()), so at the factory lineclientis the handle wrapper (not directly readable). Observe the ceiling where it is a plain value instead — in the reload path and in the rebuilt filter:Caveats:
updated_clientis move-consumed onreload.rs:71, so read it at the stop, before stepping past; these require a debug (unoptimized) build (under--releasethe locals can be optimized away); line numbers are tied to this revision, so the code line is named in each comment.🤖 Generated with Claude Code