fix(r): let $server(data_source=) survive a second session safely - #306
fix(r): let $server(data_source=) survive a second session safely#306cpsievert wants to merge 9 commits into
Conversation
aaa34cb to
85b642d
Compare
4b1ff27 to
effeb69
Compare
…data_source=) behavior Parity with the R tests in #306: unnamed registration replaces the config-time table without cleaning it up, an explicit table_name adds alongside it, and the registry is shared and cumulative across sessions.
* fix(py): greeting reflects the session that registered its table The welcome greeting is generated asynchronously, after .server() already returns, by asking the LLM to describe the registered table(s). It reads the shared, mutable QueryChatGreeter.tables and QueryChat instance's data sources at that later point -- so a later session's own server(data_source=...) call can mutate both before an earlier session's greeting actually runs, and that earlier session's greeting ends up describing the wrong table. .server() now captures greeting_tables=list(self.greeter.tables) synchronously at call time and threads it (alongside the already per-session data_sources snapshot) through mod_server() to a new private QueryChatGreeter._generate_async_snapshot(), so the async greeting no longer reads live, shared state at generation time. Kept off the public generate()/generate_async()/build_client() API -- this is an internal fix for a race the per-session pattern introduces, not a new capability. * docs(py): trim comments/docstrings to what isn't inferable from the code Also applies ruff format to test_shiny_module.py (two of the three spots were added in this stack; the third is pre-existing). * test(py): lock in mixed config-time add_table() + per-session server(data_source=) behavior Parity with the R tests in #306: unnamed registration replaces the config-time table without cleaning it up, an explicit table_name adds alongside it, and the registry is shared and cumulative across sessions.
fb26d0e to
866311f
Compare
$add_table() (which $server(data_source=) calls internally) refuses to run once .server_initialized is TRUE -- a guard meant to stop reconfiguring tables after a conversation is underway, but which also blocked a second Shiny session's own $server(data_source=) call. Even bypassing that, replacing a table cleaned up the resource (and cached query executor) an earlier, still-running session depended on, and the greeting_arg closure read live private$.data_sources/greeter$tables at async-invocation time, so a later session's registration could leak into an earlier session's greeting. Extracts $add_table()'s guard-free core into a private add_or_replace_table(), used directly by $server()'s data_source= path with cleanup_replaced = FALSE so it no longer tears down an earlier session's resource. $server() now also snapshots greeter$tables and passes it (plus the already-per-session data_sources) through mod_server() to greeter$build_client(), so the lazily-invoked greeting reads a point-in-time snapshot instead of live, mutable state. Mirrors the equivalent Python fixes (#302, #303,
add_or_replace_table() appended table_name to greeter$tables unconditionally, so each session's $server(data_source=) call added another copy of the same name, and later sessions' greeting snapshots would list the table repeatedly. Match Python's dedup guard.
…ata_source=) behavior Covers: same-name (or unnamed) registration replaces the config-time table without cleaning it up; an explicit table_name adds alongside it; and the registry is shared and cumulative, so a later session's snapshot includes an earlier session's differently-named table.
7d308d2 to
278adee
Compare
This comment was marked as resolved.
This comment was marked as resolved.
…t greeting data_description - Track live sessions on the instance (.active_sessions, incremented per $server() call, decremented via session$onSessionEnded()) and key shared-state decisions off it: $server(data_source=) cleans up the resource it replaces exactly when no session is live (cleanup_replaced = .active_sessions == 0), and the $add_table()/$add_tables()/$remove_table() guards block only while a session is active. A resource registered by a session that has since ended is reclaimed when replaced; a missed onSessionEnded degrades to over-conservative cleanup skipping, never premature cleanup. - The greeting snapshot now also captures data_description, so a later session's re-inferred description can't leak into an earlier session's lazily-generated greeting. - The mod_server() greeting test now asserts the snapshot args (tables, data_sources, data_description) are actually forwarded to greeter$build_client(), not just the base client.
c059108 to
bbfe168
Compare
…deferred name validation Address Copilot review on #306: - Retain sources/executors replaced while sessions are live (.retired_resources) and clean them up when the last live session ends (or $cleanup() runs), fixing a leaked executor/connection when cleanup_replaced was FALSE. Mirrors the Python side (#308). - greeter$build_client() distinguishes an omitted data_sources/ data_description override (falls back to live state) from an explicit snapshot, which may itself be NULL -- a session whose snapshot had no inferred description no longer picks up a later session's. - QueryChat$new(NULL, table_name = ...) validates the deferred name at construction instead of failing later at $server() registration.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved resource cleanup, session rollback, shared-resource, and snapshot issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (5)
pkg-r/R/QueryChat.R:1277
- If cleanup of the current executor or any current data source throws, control flow exits before this new
flush_retired_resources()call. That means$cleanup()can leave the resources retained from live-session replacements open, despite this being the fallback cleanup path. Register the retired-resource flush withon.exit()(or make the current-resource cleanup best-effort) so it runs even when another cleanup fails.
for (source in private$.data_sources) {
source$cleanup()
}
private$flush_retired_resources()
pkg-r/R/QueryChat.R:1198
- The active-session count is incremented before
build_query_executor(), client resolution, history validation, andmod_server()can finish. If any of those operations errors and the caller catches it while the Shiny session remains alive, no rollback runs: later table mutations stay blocked and replacements conservatively skip cleanup indefinitely. Register the count only after setup succeeds, or add anon.exit()rollback until the server setup is complete.
private$.active_sessions <- private$.active_sessions + 1L
session$onSessionEnded(function() {
pkg-r/R/QueryChat.R:195
normalize_data_source()has already created an ownedDataFrameSource/DBISourceby this point, but this compatibility check is outside thetryCatchthat cleans a staged source. A per-session registration that mixes source types therefore throws while leaking the new connection. Include normalization/compatibility/staging in the cleanup-on-error block, or explicitly cleannormalizedbefore rethrowing.
check_source_compatibility(other_sources, normalized, table_name)
pkg-r/R/QueryChatGreeter.R:16
- The updated
client_factorycontract still omits the newdata_descriptionargument even thoughbuild_client()forwards it and the implementation requires it. This leaves the internal API documentation inconsistent with the actual callback signature; please include the parameter here.
#' @param client_factory function(tables, prompt, base, data_sources) returning a configured greeting client.
pkg-r/R/querychat_module.R:57
- These overrides are still lazy promises:
$server()passesself$greeter$tablesandprivate$.data_descriptionas expressions, whilemod_server()only reads them inside the latergreeting_argcallback. A later session can therefore mutate both before the greeting is built, so the advertised point-in-time snapshot is not actually captured. Force these arguments atmod_server()entry (or assign forced locals in$server()) before registeringmoduleServer.
greeting_tables = NULL,
greeting_data_description = NULL
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
Address latest Copilot review on #306: - $cleanup() flushes retired resources via on.exit(), so the fallback cleanup path runs even if current executor/source cleanup throws. - $server() counts a session (and arms its end-of-session flush) only after setup fully succeeds, so a failed call on a live session no longer blocks table mutations or defers replacement cleanup forever. - add_or_replace_table() runs the source-compatibility check inside the cleanup-on-error block, so a failed per-session registration doesn't leak the staged source's connection. - Table replacement skips cleanup/retirement when old and new sources wrap the same DBI connection (two sessions passing the same raw connection each get their own wrapper); the replacement inherits ownership instead of being disconnected out from under. Applies to both $server(data_source=) and $add_table(replace=TRUE). - mod_server() forces greeting_tables/greeting_data_description at entry so the deferred greeting callback sees the session's point-in-time snapshot, not a later session's mutation. - QueryChatGreeter docs: client_factory signature now includes data_description.
|
Copilot review feedback addressed in 9cde4cd:
Note: (1) slightly changes Full |
There was a problem hiding this comment.
🟡 Changes recommended
Resource ownership, cleanup retry, and registration atomicity issues remain unresolved in QueryChat.R.
Get a fresh assessment by requesting another Copilot review.
Review details
Files not reviewed (4)
- pkg-r/man/DBISource.Rd: Generated file
- pkg-r/man/DataFrameSource.Rd: Generated file
- pkg-r/man/PinSource.Rd: Generated file
- pkg-r/man/TblSqlSource.Rd: Generated file
Suppressed comments (2)
pkg-r/R/QueryChat.R:263
- Clearing
.retired_resourcesbefore cleanup and suppressing errors permanently drops any resource whose cleanup fails. A transient DB/engine cleanup error can therefore leave the connection or executor unreachable, and later$cleanup()calls cannot retry it; retain failed entries while continuing through the remaining resources.
private$.retired_resources <- list()
for (resource in retired) {
# Best-effort: one failing cleanup must not leave the rest open.
tryCatch(resource$cleanup(), error = function(e) NULL)
pkg-r/R/QueryChat.R:199
- Because
auto_fill_data_description()mutates.data_descriptionand its mode, calling it beforecheck_source_compatibility()makes a failed per-session registration non-atomic. For example, replacing a single inferredPinSourcewith an incompatible data-frame source clears the existing inferred description even though the old source remains registered. Validate before mutating and restore both fields on any staging error.
private$auto_fill_data_description(next_sources)
- Files reviewed: 9/13 changed files
- Comments generated: 1
- Review effort level: Lite
| if ( | ||
| !is.null(old_source) && | ||
| !identical(old_source, normalized) && | ||
| !shares_underlying_connection(old_source, normalized) |
Address latest Copilot review on #306: - add_or_replace_table() validates source compatibility before mutating .data_description, and restores both description fields on any staging error, so a failed per-session registration leaves the existing registration fully intact. - flush_retired_resources() retains entries whose cleanup fails, so a transient error no longer permanently drops a resource; the next flush (or $cleanup()) retries it. - flush_retired_resources() skips retired sources that share their underlying DBI connection with a current data source: registrations alternating connections (C1 -> C2 -> C1) while sessions are live no longer disconnect the live connection.
Stacked on #305
Depends on #305 (deferred-construction ergonomics) — review that first. Two notes on top of the original change:
$add_table()'s single-table id auto-rename entirely (matching Python's set-id-once semantics), rather than pinning the id — so there's no.id_pinnedguard to carry intoadd_or_replace_table()here; the rename block is simply gone.greeter$tablesis now deduplicated, so per-session registration doesn't append the same table name once per session.The problem
Some deployments can only create a table's data source inside the Shiny server function — for example, on Posit Connect, a per-user database connection depends on that user's session-scoped OAuth credentials, which don't exist until the server function runs. querychat's R package already supports this "deferred" pattern: construct
QueryChat$new(NULL, table_name = ...), then register the real data source per session via$server(data_source = ...).In practice this only ever worked for the first session:
"Cannot add tables after server initialization."—$server(data_source=)calls the public$add_table(), which refuses to run once a session has started.$server(data_source=)registers underreplace = TRUE, and replacing a table cleans up the one it replaces — closing a database connection, disposing a cached query executor — even though an earlier, still-running session's own chat may still be querying through it.private$.data_sources,greeter$tables,private$.data_description), so a later session's registration could leak into an earlier session's greeting.This mirrors the equivalent bug already fixed in Python (#302, #303, #304); see #300 for the original writeup covering both languages.
The fix
$add_table()'s guard-free core (name validation, staging the table, rebuilding the system prompt/executor cache, greeting inclusion) is extracted into a privateadd_or_replace_table().$add_table()keeps its guard and calls into that core;$server(data_source=...)calls the core directly, bypassing the guard for this path only..active_sessions, incremented per$server()call, decremented viasession$onSessionEnded()), and shared-state decisions key off it:add_or_replace_table()gainscleanup_replaced = TRUE;$server()'s internal call passescleanup_replaced = private$.active_sessions == 0, so a replaced resource (and the cached query executor) is cleaned up exactly when no running session could still be using it. The public$add_table(replace = TRUE)is unaffected — that path still has exactly one owner.$add_table()/$add_tables()/$remove_table()guards block only while a session is actually live, rather than forever after the first$server()call — so a resource registered by a session that has since ended is reclaimed when a later session replaces it.onSessionEndednever fires, behavior degrades to over-conservative cleanup skipping, never premature cleanup.$server()capturesgreeting_tablesandgreeting_data_descriptionsnapshots synchronously at call time and threads them (along with the already-per-sessiondata_sourcessnapshotmod_server()already receives) through togreeter$build_client(), so the lazily-invoked greeting is built from point-in-time snapshots instead of reading live, mutable state whenever the greeting callback actually runs.Matches the Python side, #308.
Test plan
test-server_data_source.R: a second session's$server(data_source=)call succeeds without raising; the public$add_table()guard is enforced while a session is live; a second session's call does not clean up the first session's data source or cached query executor (while the publicadd_table(replace=TRUE)path still does, locking in unchanged behavior);$server()passesgreeting_tables/greeting_data_descriptionsnapshots tomod_server(), which forwards them (with the per-sessiondata_sources) togreeter$build_client(); per-session registration does not duplicategreeter$tables; mixing config-time$add_table()with per-session$server(data_source=)behaves as documented (unnamed registration replaces the config-time table (cleaned up when no session is live, left alone while one is), an explicittable_nameadds alongside it, and the registry is shared across sessions).$add_table()is allowed once all sessions have ended.air format --checkis clean and the full test suite passes locally (2123 tests), including fix(r): make table_name optional for deferred QueryChat$new(NULL) #305's deferred-construction tests.Review follow-ups (Copilot)
.retired_resources) and cleaned up when the last live session ends (or$cleanup()runs) — closing the executor/connection leak in thecleanup_replaced = FALSEpath. Matches the Python side (fix(py): scope server(data_source=) cleanup and table guards to live sessions #308).greeter$build_client()distinguishes an omitteddata_sources/data_descriptionoverride (live fallback) from an explicit snapshot, which may itself beNULL— a session whose snapshot had no inferred description can't pick up a later session's.QueryChat$new(NULL, table_name = ...)validates the deferred name at construction instead of failing later at$server()registration.