diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 8f7f9f83..17159acf 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -72,6 +72,8 @@ * `$add_table()` no longer rewrites the Shiny module `$id` when the registered table is the only one; the id is now fixed at construction time, matching Python. The rewrite could desync the module namespace from an already-rendered UI when a table was registered between `$ui()` and `$server()` (e.g. via `$server(data_source = )`). (#305) +* `$server(data_source = )` — used for the deferred pattern where a table's data source can only be created inside the server function (e.g. a per-user database connection scoped to Posit Connect OAuth credentials) — only ever worked for the first Shiny session: a second session hit `"Cannot add tables after server initialization"`, and even bypassing that, replacing a table cleaned up the resource an earlier, still-running session depended on, and the auto-generated greeting could reflect the wrong session's table or inferred data description. All three are fixed: each session can now register its own data source, and neither the replaced resource nor the greeting is affected by a later session's registration. Resource cleanup and the `$add_table()`/`$remove_table()` guards now key off *live* sessions (tracked via `session$onSessionEnded()`): a resource registered by a session that has since ended is reclaimed when replaced, and a resource replaced while sessions are still live is retained and cleaned up once the last live session ends (or `$cleanup()` runs). (#300) + # querychat 0.3.0 ## New features diff --git a/pkg-r/R/DBISource.R b/pkg-r/R/DBISource.R index 725c02d8..572ad89d 100644 --- a/pkg-r/R/DBISource.R +++ b/pkg-r/R/DBISource.R @@ -212,6 +212,14 @@ DBISource <- R6::R6Class( self$execute_query(NULL) }, + #' @description + #' Get the underlying DBI connection + #' + #' @return The DBI connection this source wraps. + get_connection = function() { + private$conn + }, + #' @description #' Disconnect from the database #' diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index 8b3b560a..8660e2e9 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -92,7 +92,13 @@ QueryChat <- R6::R6Class( .data_sources = list(), .deferred_table_name = NULL, .query_executor = NULL, - .server_initialized = FALSE, + # Live Shiny session count. Shared-state guards key off this: an ended + # session can no longer be using a resource it registered. + .active_sessions = 0L, + # Sources/executors replaced while sessions were still live. Their + # cleanup is deferred until the last live session ends (or $cleanup() + # runs) so a still-running session never loses a resource it uses. + .retired_resources = list(), .client_spec = NULL, .client_console = NULL, .system_prompt = NULL, @@ -150,6 +156,140 @@ QueryChat <- R6::R6Class( ) }, + # Guard-free core of $add_table(), also called directly by $server()'s + # per-session data_source= path (which must work even while earlier + # sessions are still running). cleanup_replaced = FALSE is for that + # path: the replaced table may still be in use by an earlier session, so + # the retired source and cached executor are retained and cleaned up + # once the last live session ends (see $server()). + add_or_replace_table = function( + data_source, + table_name, + replace = FALSE, + include_in_greeting = FALSE, + cleanup_replaced = TRUE + ) { + check_bool(include_in_greeting) + check_sql_table_name(table_name) + if (table_name %in% names(private$.data_sources) && !replace) { + cli::cli_abort( + "Table {.val {table_name}} already exists. Use {.code replace = TRUE} to replace." + ) + } + if ( + is_data_source(data_source) && + !identical(data_source$table_name, table_name) + ) { + cli::cli_abort( + c( + "{.arg data_source}'s own table name ({.val {data_source$table_name}}) does not match the given {.arg table_name} ({.val {table_name}}).", + "i" = "Pass a matching {.arg table_name}, or omit it to use {.val {data_source$table_name}}." + ) + ) + } + normalized <- normalize_data_source(data_source, table_name) + + other_sources <- private$.data_sources[ + names(private$.data_sources) != table_name + ] + + next_sources <- private$.data_sources + next_sources[[table_name]] <- normalized + + # Snapshot so a staging failure can be rolled back: a failed + # registration must leave the existing registration fully intact. + prev_description <- private$.data_description + prev_description_mode <- private$.data_description_mode + tryCatch( + { + check_source_compatibility(other_sources, normalized, table_name) + private$auto_fill_data_description(next_sources) + private$build_system_prompt(data_sources = next_sources) + }, + error = function(e) { + private$.data_description <- prev_description + private$.data_description_mode <- prev_description_mode + # A source normalized here (not user-supplied) owns a fresh + # connection; don't leak it when staging fails. + if (!inherits(data_source, "DataSource")) { + normalized$cleanup() + } + stop(e) + } + ) + + old_source <- private$.data_sources[[table_name]] + private$.data_sources <- next_sources + if ( + !is.null(old_source) && + !identical(old_source, normalized) && + !shares_underlying_connection(old_source, normalized) + ) { + if (cleanup_replaced) { + old_source$cleanup() + } else { + private$.retired_resources <- c( + private$.retired_resources, + list(old_source) + ) + } + } + + if (!is.null(private$.query_executor)) { + if (cleanup_replaced) { + tryCatch(private$.query_executor$cleanup(), error = function(e) NULL) + } else { + private$.retired_resources <- c( + private$.retired_resources, + list(private$.query_executor) + ) + } + private$.query_executor <- NULL + } + + # Guard against duplicates: the per-session $server(data_source=) path + # reaches this once per session under the same table name. + if ( + isTRUE(include_in_greeting) && !(table_name %in% self$greeter$tables) + ) { + self$greeter$tables <- c(self$greeter$tables, table_name) + } + + invisible(NULL) + }, + + # Clean up resources retired while sessions were still live. Retired + # resources may still be in use by a live session, so this only runs + # once no sessions remain (or from $cleanup()). + flush_retired_resources = function() { + retired <- private$.retired_resources + private$.retired_resources <- list() + for (resource in retired) { + # Registrations can alternate connections (C1 -> C2 -> C1) while + # sessions are live, so a retired wrapper may share its connection + # with the now-current source, which owns it. Never clean those. + in_use <- any(vapply( + private$.data_sources, + function(source) shares_underlying_connection(resource, source), + logical(1) + )) + if (in_use) { + next + } + # Best-effort: one failing cleanup must not leave the rest open, but + # retain the failed entry so a later flush (or $cleanup()) can retry. + tryCatch( + resource$cleanup(), + error = function(e) { + private$.retired_resources <- c( + private$.retired_resources, + list(resource) + ) + } + ) + } + }, + create_session_client = function( client_spec = NULL, tools = NA, @@ -387,10 +527,12 @@ QueryChat <- R6::R6Class( self$greeter$tables <- c(self$greeter$tables, normalized$table_name) self$id <- id %||% sprintf("querychat_%s", normalized$table_name) } else { - # Deferred pattern: data_source is NULL. table_name is optional here; - # explicit NULL is treated the same as omitting it. + # An explicit table_name = NULL is treated the same as omitting it. table_name_given <- !is_missing(table_name) && !is.null(table_name) if (table_name_given) { + # Validate now: a bad deferred name would otherwise only surface at + # $server() registration time, after the module id is built. + check_sql_table_name(table_name) private$.deferred_table_name <- table_name } default_id <- if (table_name_given) { @@ -431,65 +573,15 @@ QueryChat <- R6::R6Class( replace = FALSE, include_in_greeting = FALSE ) { - if (private$.server_initialized) { - cli::cli_abort("Cannot add tables after server initialization.") - } - check_bool(include_in_greeting) - check_sql_table_name(table_name) - if (table_name %in% names(private$.data_sources) && !replace) { - cli::cli_abort( - "Table {.val {table_name}} already exists. Use {.code replace = TRUE} to replace." - ) + if (private$.active_sessions > 0) { + cli::cli_abort("Cannot add tables while a server session is active.") } - if ( - is_data_source(data_source) && - !identical(data_source$table_name, table_name) - ) { - cli::cli_abort( - c( - "{.arg data_source}'s own table name ({.val {data_source$table_name}}) does not match the given {.arg table_name} ({.val {table_name}}).", - "i" = "Pass a matching {.arg table_name}, or omit it to use {.val {data_source$table_name}}." - ) - ) - } - normalized <- normalize_data_source(data_source, table_name) - - other_sources <- private$.data_sources[ - names(private$.data_sources) != table_name - ] - check_source_compatibility(other_sources, normalized, table_name) - - next_sources <- private$.data_sources - next_sources[[table_name]] <- normalized - - private$auto_fill_data_description(next_sources) - tryCatch( - { - private$build_system_prompt(data_sources = next_sources) - }, - error = function(e) { - if (!inherits(data_source, "DataSource")) { - normalized$cleanup() - } - stop(e) - } + private$add_or_replace_table( + data_source, + table_name, + replace = replace, + include_in_greeting = include_in_greeting ) - - old_source <- private$.data_sources[[table_name]] - private$.data_sources <- next_sources - if (!is.null(old_source) && !identical(old_source, normalized)) { - old_source$cleanup() - } - - if (!is.null(private$.query_executor)) { - tryCatch(private$.query_executor$cleanup(), error = function(e) NULL) - private$.query_executor <- NULL - } - - if (isTRUE(include_in_greeting)) { - self$greeter$tables <- c(self$greeter$tables, table_name) - } - invisible(self) }, @@ -518,8 +610,8 @@ QueryChat <- R6::R6Class( replace = FALSE, include_in_greeting = FALSE ) { - if (private$.server_initialized) { - cli::cli_abort("Cannot add tables after server initialization.") + if (private$.active_sessions > 0) { + cli::cli_abort("Cannot add tables while a server session is active.") } if (!inherits(conn, "DBIConnection")) { cli::cli_abort( @@ -588,7 +680,8 @@ QueryChat <- R6::R6Class( old_source <- private$.data_sources[[table_name]] if ( !is.null(old_source) && - !identical(old_source, normalized[[table_name]]) + !identical(old_source, normalized[[table_name]]) && + !shares_underlying_connection(old_source, normalized[[table_name]]) ) { old_source$cleanup() } @@ -614,8 +707,8 @@ QueryChat <- R6::R6Class( #' #' @return Invisibly returns `self` for chaining. remove_table = function(table_name) { - if (private$.server_initialized) { - cli::cli_abort("Cannot remove tables after server initialization.") + if (private$.active_sessions > 0) { + cli::cli_abort("Cannot remove tables while a server session is active.") } if (!table_name %in% names(private$.data_sources)) { cli::cli_abort("Table {.val {table_name}} not found.") @@ -1121,18 +1214,19 @@ QueryChat <- R6::R6Class( ) ) } - self$add_table( + private$add_or_replace_table( data_source, tbl_name, replace = TRUE, - include_in_greeting = TRUE + include_in_greeting = TRUE, + # A live session may still be using the replaced source, so defer + # its cleanup until no sessions are active. + cleanup_replaced = private$.active_sessions == 0 ) } private$require_initialized("$server") - private$.server_initialized <- TRUE - if (is.null(private$.query_executor)) { private$.query_executor <- build_query_executor(private$.data_sources) } @@ -1174,8 +1268,22 @@ QueryChat <- R6::R6Class( tools = self$tools, history = resolved_history, greeter = self$greeter, - greeting_base = base_client + greeting_base = base_client, + greeting_tables = self$greeter$tables, + greeting_data_description = private$.data_description ) + + # Count the session (and arm the end-of-session flush) only after setup + # has fully succeeded: a failed $server() call on a still-live session + # must not block table mutations or defer replacement cleanup forever. + private$.active_sessions <- private$.active_sessions + 1L + session$onSessionEnded(function() { + private$.active_sessions <- private$.active_sessions - 1L + if (private$.active_sessions == 0) { + private$flush_retired_resources() + } + }) + result }, @@ -1197,6 +1305,9 @@ QueryChat <- R6::R6Class( #' #' @return Invisibly returns `NULL`. Resources are cleaned up internally. cleanup = function() { + # The retired-resource flush is the fallback cleanup path; make sure it + # runs even if cleaning the current executor or sources throws. + on.exit(private$flush_retired_resources(), add = TRUE) if (!is.null(private$.query_executor)) { private$.query_executor$cleanup() } @@ -1217,11 +1328,25 @@ QueryChat <- R6::R6Class( return(invisible(value)) } if (is.null(private$.greeter)) { - client_factory <- function(tables, prompt, base = NULL) { + client_factory <- function( + tables, + prompt, + base = NULL, + data_sources, + data_description + ) { + # An explicit snapshot (possibly NULL) wins over live state; only + # an omitted override falls back to it. + if (missing(data_sources)) { + data_sources <- private$.data_sources + } + if (missing(data_description)) { + data_description <- private$.data_description + } sp <- QueryChatSystemPrompt$new( prompt_template = prompt, - data_sources = private$.data_sources, - data_description = private$.data_description, + data_sources = data_sources, + data_description = data_description, extra_instructions = NULL, categorical_threshold = private$.categorical_threshold, data_dicts = private$.data_dicts, @@ -1490,6 +1615,19 @@ normalize_data_source <- function(data_source, table_name) { ) } +# Do the two sources wrap the same DBI connection? Each registration of a raw +# connection gets its own DBISource wrapper, so two sources can share one +# connection; the replacement then inherits ownership of it, and cleaning the +# replaced wrapper would disconnect the connection out from under it. +shares_underlying_connection <- function(old_source, new_source) { + if ( + !inherits(old_source, "DBISource") || !inherits(new_source, "DBISource") + ) { + return(FALSE) + } + identical(old_source$get_connection(), new_source$get_connection()) +} + normalize_data_dicts <- function(data_dict) { if (is.null(data_dict)) { return(list()) diff --git a/pkg-r/R/QueryChatGreeter.R b/pkg-r/R/QueryChatGreeter.R index 1af88edf..02f4f016 100644 --- a/pkg-r/R/QueryChatGreeter.R +++ b/pkg-r/R/QueryChatGreeter.R @@ -13,7 +13,7 @@ QueryChatGreeter <- R6::R6Class( .prompt = NULL ), public = list( - #' @param client_factory function(tables, prompt, base) returning a configured greeting client. + #' @param client_factory function(tables, prompt, base, data_sources, data_description) returning a configured greeting client. initialize = function(client_factory) { private$.client_factory <- client_factory private$.tables <- character() @@ -26,8 +26,40 @@ QueryChatGreeter <- R6::R6Class( #' @description Build a fresh greeting client (no history) configured with the greeting system prompt. #' @param base Optional resolved client to clone (resolve-once base from `$server()`). - build_client = function(base = NULL) { - private$.client_factory(private$.tables, private$.prompt, base) + #' @param tables Advanced/internal: overrides `$tables` for this call only. + #' Used by `mod_server()` to build the greeting from a point-in-time + #' snapshot captured when a Shiny session's `$server()` call ran, rather + #' than the live (and possibly since-mutated) `$tables`. + #' @param data_sources Advanced/internal: overrides the QueryChat + #' instance's data sources for this call only, for the same reason as + #' `tables`. + #' @param data_description Advanced/internal: overrides the QueryChat + #' instance's inferred data description for this call only, for the same + #' reason as `tables`. + #' + #' For `data_sources` and `data_description`, an explicitly passed value + #' (including `NULL`) is the snapshot and wins over live state; only an + #' omitted argument falls back to live state. + build_client = function( + base = NULL, + tables = NULL, + data_sources, + data_description + ) { + args <- list( + tables %||% private$.tables, + private$.prompt, + base + ) + # Single-bracket assignment: `$<-` would delete the element when the + # snapshot value is NULL, turning an explicit NULL back into "omitted". + if (!missing(data_sources)) { + args["data_sources"] <- list(data_sources) + } + if (!missing(data_description)) { + args["data_description"] <- list(data_description) + } + do.call(private$.client_factory, args) }, #' @description Generate a greeting synchronously and return it as text. diff --git a/pkg-r/R/querychat_module.R b/pkg-r/R/querychat_module.R index 710f2104..79066fbb 100644 --- a/pkg-r/R/querychat_module.R +++ b/pkg-r/R/querychat_module.R @@ -52,8 +52,17 @@ mod_server <- function( tools, history, greeter = NULL, - greeting_base = NULL + greeting_base = NULL, + greeting_tables = NULL, + greeting_data_description = NULL ) { + # These arrive as lazy promises over live QueryChat state and are read only + # inside the deferred greeting callback below; force them now so the + # greeting reflects the point-in-time snapshot captured when this session's + # $server() call ran, not a later session's mutation. + force(greeting_tables) + force(greeting_data_description) + shiny::moduleServer(id, function(input, output, session) { current_table_val <- shiny::reactiveVal(NULL, label = "current_table") @@ -130,7 +139,12 @@ mod_server <- function( "i" = "For faster startup, lower cost, and determinism, consider providing a {.arg greeting} to {.fn QueryChat}.", "i" = "You can use your {.help querychat::QueryChat} object's {.fn $generate_greeting} method to generate a greeting." )) - greeting_client <- greeter$build_client(greeting_base) + greeting_client <- greeter$build_client( + greeting_base, + tables = greeting_tables, + data_sources = data_sources, + data_description = greeting_data_description + ) stream <- greeting_client$stream_async(GREETING_PROMPT) shinychat::chat_greeting(stream, persistent = TRUE) } diff --git a/pkg-r/man/DBISource.Rd b/pkg-r/man/DBISource.Rd index 539af25a..f6132774 100644 --- a/pkg-r/man/DBISource.Rd +++ b/pkg-r/man/DBISource.Rd @@ -42,6 +42,7 @@ db_source$cleanup() \item \href{#method-DBISource-execute_query}{\code{DBISource$execute_query()}} \item \href{#method-DBISource-test_query}{\code{DBISource$test_query()}} \item \href{#method-DBISource-get_data}{\code{DBISource$get_data()}} + \item \href{#method-DBISource-get_connection}{\code{DBISource$get_connection()}} \item \href{#method-DBISource-cleanup}{\code{DBISource$cleanup()}} \item \href{#method-DBISource-clone}{\code{DBISource$clone()}} } @@ -200,6 +201,21 @@ all original table columns (default: \code{FALSE})} } } +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-DBISource-get_connection}{}}} +\subsection{\code{DBISource$get_connection()}}{ + Get the underlying DBI connection + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{DBISource$get_connection()} + \if{html}{\out{
}} + } + \subsection{Returns}{ + The DBI connection this source wraps. + } +} + \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-DBISource-cleanup}{}}} diff --git a/pkg-r/man/DataFrameSource.Rd b/pkg-r/man/DataFrameSource.Rd index b7402f4f..7ee3c9f6 100644 --- a/pkg-r/man/DataFrameSource.Rd +++ b/pkg-r/man/DataFrameSource.Rd @@ -53,6 +53,7 @@ df_sqlite$cleanup()