diff --git a/pkg-r/DESCRIPTION b/pkg-r/DESCRIPTION index 9b770cbf..ea520960 100644 --- a/pkg-r/DESCRIPTION +++ b/pkg-r/DESCRIPTION @@ -27,6 +27,7 @@ Imports: duckdb (>= 1.5.4.2), ellmer (>= 0.4.1), evaluate, + filelock, glue, highr, htmltools, diff --git a/pkg-r/NAMESPACE b/pkg-r/NAMESPACE index c9004cba..94ac6310 100644 --- a/pkg-r/NAMESPACE +++ b/pkg-r/NAMESPACE @@ -2,6 +2,7 @@ export(commons) export(commons_app) +export(commons_prewarm) export(commons_server) export(commons_theme) export(context_layer) diff --git a/pkg-r/R/chat.R b/pkg-r/R/chat.R index 7d1bba23..42c6c810 100644 --- a/pkg-r/R/chat.R +++ b/pkg-r/R/chat.R @@ -111,14 +111,7 @@ commons_server <- function(id, client, ...) { attributes = list("commons.server.id" = id) ) - # Build the context index and start the background pin-cache download - # during post-startup idle time (while the user reads the welcome message). - # Errors are swallowed: the first search retries the index build and - # surfaces the failure to the model, and an unwarmed pin is simply - # downloaded at its first use. - later::later(function() { - tryCatch(client$prewarm(), error = function(err) NULL) - }) + prewarm_on_idle(client) chat <- shinychat::chat_server(id, client = client, ...) # shinychat owns the conversation identity (it sets the client's @@ -130,6 +123,139 @@ commons_server <- function(id, client, ...) { chat } +#' Pre-warm a commons agent's caches ahead of deployment +#' +#' A [commons()] agent does some expensive setup the first time it needs to: +#' building the search index over the context layer and downloading any +#' uncached pins (see [data_source()]). When you serve the agent with +#' [commons_server()] or [commons_app()], this warming already happens +#' automatically during post-startup idle time, so the first question is +#' (hopefully) fast — you (hopefully) don't need to call +#' `commons_prewarm()` yourself. +#' +#' The reason to call it directly is to warm the caches *without* running +#' the app. Both kinds of setup are cached on disk — the context index is +#' built once per version of your context documents, and pins are +#' downloaded once into the local pins cache — so running +#' `commons_prewarm(agent, cache_dir)` in a script before deploying lets +#' the deployed app start warm. +#' +#' Pre-warming is a pure optimization — anything it builds is rebuilt on +#' demand if it's missing — so failures are reported as warnings rather +#' than errors. If a pre-deploy script should fail the deploy when warming +#' fails, call the agent's `prewarm()` method directly instead; it lets +#' errors propagate. +#' +#' @section Cache configuration: +#' You can usually ignore this section: by default the context index cache +#' lives in a per-user directory that does the right thing locally and on +#' most hosted platforms. The reasons to configure it are: +#' +#' * **Persistence across deployments on ephemeral hosts.** This is +#' already handled on Connect and Shiny Server: the cache automatically +#' lands in Connect's persistent data directory when the server provides +#' one (currently an early-access feature the administrator enables), or +#' in an `app_cache/` directory beside the app, which survives +#' redeploys. But on hosts where no local disk persists (e.g. Connect +#' Cloud, which resets disk to the deployed bundle and never sets a data +#' directory), the cache is rebuilt after every redeploy unless you +#' point it at persistent storage yourself. +#' * **Shipping a warm cache with the app.** Run +#' `commons_prewarm(agent, cache_dir = "path/inside/the/app")` before +#' deploying, and the deployed bundle includes the pre-built index. +#' `cache_dir` is required for this reason — anything resolved +#' implicitly (a per-user cache directory) would not ship with the +#' deployment. +#' * **Development loops.** If you're editing context documents and want +#' each change re-indexed from scratch, disable persistence with +#' `options(commons.context_cache = FALSE)`. +#' +#' Set the directory with `options(commons.context_cache = "path/to/dir")` +#' or the `COMMONS_CONTEXT_CACHE` environment variable. The cache is +#' capped at 256 MB with least-recently-used eviction; raise +#' `options(commons.context_cache_max_size)` (in bytes) if you index very +#' large context. +#' +#' @param client A [commons()] agent. +#' @param cache_dir A directory for the context index cache, used for this +#' call only (equivalent to setting +#' `options(commons.context_cache = cache_dir)` around it). Point it at +#' a directory inside the app so the warmed index ships with the +#' deployment. Note that rsconnect excludes `app_cache/` from deployed +#' bundles, so pick another name. +#' +#' @return `NULL`, invisibly. +#' +#' @examples +#' \dontrun{ +#' # In a pre-deploy script: warm the caches into a directory inside the +#' # app, so the deployed bundle includes the pre-built context index +#' agent <- commons( +#' ellmer::chat_anthropic(), +#' data_sources = data_source(sales = sales) +#' ) +#' # (not app_cache/, which rsconnect excludes from the bundle) +#' commons_prewarm(agent, cache_dir = "commons-cache") +#' } +#' +#' @export +commons_prewarm <- function(client, cache_dir) { + check_commons_client(client) + if (missing(cache_dir)) { + cli::cli_abort(c( + "{.arg cache_dir} is required.", + i = "Point it at a directory inside the app (e.g. {.code \"commons-cache\"}) so the warmed cache ships with the deployment." + )) + } + if (!rlang::is_string(cache_dir)) { + cli::cli_abort("{.arg cache_dir} must be a path to a cache directory.") + } + tryCatch( + { + withr::with_options(list(commons.context_cache = cache_dir), { + client$prewarm() + prewarm_cache_hint(cache_dir) + }) + }, + error = function(err) { + msg <- conditionMessage(err) + cli::cli_warn("{msg}") + } + ) + invisible(NULL) +} + +# An error escaping a later::later() callback would stop the app, so +# downgrade failures to warnings. +prewarm_on_idle <- function(client) { + later::later(function() { + tryCatch( + client$prewarm(), + error = function(err) { + msg <- conditionMessage(err) + cli::cli_warn("{msg}") + } + ) + }) + invisible(NULL) +} + +prewarm_cache_hint <- function(cache_dir) { + store_dir <- context_store_dir(cache_dir) + if (!dir.exists(store_dir)) { + cli::cli_warn(c( + "No context index was cached at {.path {cache_dir}}.", + i = "The agent has no context layer to index, so the deployed app has nothing to reuse." + )) + return(invisible()) + } + cli::cli_inform(c( + "Warmed the context index cache at {.path {store_dir}}.", + i = "To reuse it, deploy the directory with the app and set {.code options(commons.context_cache = \"{cache_dir}\")} in the app, or the {.envvar COMMONS_CONTEXT_CACHE} environment variable on the server." + )) + invisible() +} + check_chat_packages <- function(call = rlang::caller_env()) { missing <- c("htmltools", "shiny", "shinychat")[ !vapply( diff --git a/pkg-r/R/commons.R b/pkg-r/R/commons.R index 92da5671..beefcd72 100644 --- a/pkg-r/R/commons.R +++ b/pkg-r/R/commons.R @@ -390,6 +390,16 @@ Commons <- R6::R6Class( }, prewarm = function() { + # A direct call is typically warming caches ahead of deployment, so + # failures propagate: a cold cache should fail the deploy. + # commons_prewarm() and prewarm_on_idle() downgrade them to warnings. + private$prewarm_context() + private$prewarm_sources() + invisible(self) + } + ), + private = list( + prewarm_context = function() { layer <- private$context_layer layer_state <- if (is.null(layer)) NULL else context_layer_state(layer) if (!is.null(layer_state) && length(layer_state$docs) > 0) { @@ -397,18 +407,29 @@ Commons <- R6::R6Class( "commons_context_prewarm", attributes = list( "commons.context.n_docs" = length(layer_state$docs), - "commons.context.cache_hit" = !is.null(layer_state$store) + # tryCatch: telemetry must not abort prewarming (resolving the + # cache dir can fail or warn on an unwritable root). + "commons.context.cache_hit" = + !is.null(layer_state$store) || + isTRUE(tryCatch( + context_cache_enabled() && + file.exists(context_store_path(layer_state$docs)), + error = function(err) FALSE + )) ) ) context_store(layer) } + invisible(self) + }, + + prewarm_sources = function() { for (source in private$sources) { source_prewarm(source) } invisible(self) - } - ), - private = list( + }, + sources = NULL, context_layer = NULL, registry = NULL, diff --git a/pkg-r/R/context-layer.R b/pkg-r/R/context-layer.R index 2c352ccd..5d659428 100644 --- a/pkg-r/R/context-layer.R +++ b/pkg-r/R/context-layer.R @@ -106,25 +106,310 @@ strip_frontmatter <- function(md) { sub("(?s)^---\r?\n.*?\r?\n---(\r?\n|$)", "", md, perl = TRUE) } -# Store setup (duckdb creation, chunk insertion, FTS indexing) is the most -# expensive part of building an agent and many conversations never search, so -# it's deferred to the first search. Aliases of one layer share its store; -# augmenting its documents creates a layer with a fresh store. +# The context store is a persistent, content-addressed DuckDB file: the key +# hashes the layer's docs plus the ragnar/duckdb versions (whose file format +# the store depends on), so a build happens once per content version per +# cache root and later processes just open the file. Aliases of one layer +# share its store; augmenting its documents creates a layer with a fresh +# store. +context_cache_state <- new.env(parent = emptyenv()) + context_store <- function(layer) { state <- context_layer_state(layer) - if (is.null(state$store)) { - local_commons_span( - "commons_context_store_build", - attributes = list("commons.context.n_docs" = length(state$docs)) + if (!is.null(state$store)) { + return(state$store) + } + if (!context_cache_enabled()) { + store <- build_context_store_memory(state$docs) + state$store <- store + return(store) + } + path <- context_store_path(state$docs) + if (!file.exists(path)) { + build_context_store(state$docs, path) + } else { + # Touch the mtime so size-cap eviction is LRU. Best-effort -- a + # read-only cache dir still opens fine. + tryCatch(Sys.setFileTime(path, Sys.time()), error = function(err) NULL) + } + store <- tryCatch( + ragnar::ragnar_store_connect(path), + error = function(err) err + ) + if (inherits(store, "error")) { + # Typically a concurrent pruner unlinked the store between our + # file.exists() and the connect; a corrupt store file is also possible. + # Warn (and notify in Shiny, where a warning is easy to miss), then + # rebuild once -- a second failure propagates, so persistent corruption + # still surfaces. + context_store_connect_warning(path, store) + unlink(path) + build_context_store(state$docs, path) + store <- ragnar::ragnar_store_connect(path) + } + state$store <- store + store +} + +context_store_connect_warning <- function(path, err) { + # Assign first: the raw message can contain braces (DuckDB errors embed + # JSON), which cli would try to interpolate. + detail <- conditionMessage(err) + cli::cli_warn(c( + "Failed to open the cached context store at {.path {path}}; rebuilding it.", + i = "{detail}" + )) + if (is_shiny_app()) { + tryCatch( + shiny::showNotification( + "The context index is being rebuilt; the first search may be slow.", + type = "warning", + duration = 8 + ), + error = function(err) NULL + ) + } +} + +# options(commons.context_cache = FALSE) builds the index in memory per +# layer instead -- an escape hatch for development loops over context files. +context_cache_enabled <- function() { + if (identical(getOption("commons.context_cache"), FALSE)) { + return(FALSE) + } + # Env vars can't express FALSE; accept the usual spellings. + val <- Sys.getenv("COMMONS_CONTEXT_CACHE", unset = NA_character_) + if (!is.na(val) && tolower(val) %in% c("false", "0", "no")) { + return(FALSE) + } + TRUE +} + +build_context_store_memory <- function(docs) { + local_commons_span( + "commons_context_store_build", + attributes = list( + "commons.context.n_docs" = length(docs), + "commons.context.persistent" = FALSE ) - store <- ragnar::ragnar_store_create(embed = NULL) - for (doc in state$docs) { - ragnar::ragnar_store_insert(store, ragnar::markdown_chunk(doc)) + ) + store <- ragnar::ragnar_store_create(embed = NULL) + for (doc in docs) { + ragnar::ragnar_store_insert(store, ragnar::markdown_chunk(doc)) + } + ragnar::ragnar_store_build_index(store, type = "fts") + store +} + +# Build to a temp file in the same directory, then rename into place +# atomically, so a concurrent reader or builder never observes a partial +# store. If another builder wins the race, its store is equivalent content; +# discard ours and open theirs. +build_context_store <- function(docs, path) { + local_commons_span( + "commons_context_store_build", + attributes = list( + "commons.context.n_docs" = length(docs), + "commons.context.persistent" = TRUE + ) + ) + dir.create(dirname(path), recursive = TRUE, showWarnings = FALSE) + tmp <- tempfile(pattern = ".build-", tmpdir = dirname(path)) + on.exit(unlink(tmp, recursive = TRUE), add = TRUE) + + store <- ragnar::ragnar_store_create(tmp, embed = NULL) + for (doc in docs) { + ragnar::ragnar_store_insert(store, ragnar::markdown_chunk(doc)) + } + ragnar::ragnar_store_build_index(store, type = "fts") + DBI::dbDisconnect(store@con, shutdown = TRUE) + + if (!file.exists(path)) { + file.rename(tmp, path) + } + if (!file.exists(path)) { + cli::cli_abort("Failed to build the context store at {.path {path}}.") + } + prune_context_cache(dirname(path), protect = path) + invisible(path) +} + +# Content-addressed stores accumulate one file per content version, so the +# cache is capped by total size and pruned LRU (the mtime touch on open +# keeps active stores young). Like cachem, a single store larger than the +# cap is kept, with a one-time warning, rather than evicted into a rebuild +# loop. Throttled (at most once per 20 builds or per 5 seconds) since +# stat-ing the directory on every build is needlessly slow; concurrent +# pruners may double-delete, which unlink tolerates with a warning. +prune_context_cache <- function( + dir, + max_size = getOption("commons.context_cache_max_size", 256 * 1024^2), + protect = NULL +) { + now <- Sys.time() + context_cache_state$n_builds <- (context_cache_state$n_builds %||% 0) + 1 + last <- context_cache_state$last_prune + throttled <- context_cache_state$n_builds %% 20 != 0 && + !is.null(last) && + difftime(now, last, units = "secs") < 5 + if (throttled) { + return(invisible()) + } + context_cache_state$last_prune <- now + + reap_stale_build_files(dir, now) + + stores <- list.files(dir, pattern = "[.]duckdb$", full.names = TRUE) + # A store deleted by a concurrent pruner since list.files() stats as NA + sizes <- file.size(stores) + stores <- stores[!is.na(sizes)] + total <- sum(sizes, na.rm = TRUE) + if (total > max_size) { + evictable <- setdiff(stores, protect) + evictable <- evictable[order(file.mtime(evictable), na.last = TRUE)] + for (victim in evictable) { + if (total <= max_size) { + break + } + size <- file.size(victim) + if (is.na(size)) { + next + } + # Only count the eviction when the unlink actually happened -- on + # Windows, deleting a store another process holds open fails. + if (suppressWarnings(unlink(victim)) == 0) { + total <- total - size + } } - ragnar::ragnar_store_build_index(store, type = "fts") - state$store <- store + if (total > max_size && is.null(context_cache_state$warned_size)) { + context_cache_state$warned_size <- TRUE + cli::cli_warn(c( + "The context cache exceeds its size cap ({format_size(max_size)}) with only protected or in-use stores remaining.", + i = "A single store larger than the cap is kept; raise {.code options(commons.context_cache_max_size)} if this is expected." + )) + } + } + invisible() +} + +# A crashed or killed build would leak its `.build-*` temp file (the pruner +# only lists `*.duckdb`). Reap temp files older than a day: young enough to +# clear debris promptly, old enough to never delete a build in flight. +reap_stale_build_files <- function(dir, now, max_age = 24 * 60 * 60) { + stale <- list.files( + dir, + pattern = "^[.]build-", + all.files = TRUE, + full.names = TRUE + ) + age <- difftime(now, file.mtime(stale), units = "secs") + # file.mtime() is NA for a file a concurrent process just deleted + old <- stale[!is.na(age) & age > max_age] + suppressWarnings(unlink(old, recursive = TRUE)) + invisible() +} + +format_size <- function(bytes) { + if (bytes >= 1024^2) { + sprintf("%.0f MB", bytes / 1024^2) + } else { + sprintf("%.0f KB", bytes / 1024) + } +} + +context_store_path <- function(docs) { + key <- rlang::hash(c( + docs, + paste0("ragnar:", utils::packageVersion("ragnar")), + paste0("duckdb:", utils::packageVersion("duckdb")) + )) + file.path(context_store_dir(), paste0(key, ".duckdb")) +} + +context_store_dir <- function(cache_dir = context_cache_dir_safe()) { + file.path(cache_dir, "context") +} + +# Cache root resolution: an explicit override, then Connect's persistent +# data directory, then -- for Shiny apps -- an app_cache/ directory beside +# the app (sass's convention: per-app scoping on hosted platforms, used +# locally only if it already exists), then the per-user cache dir. +context_cache_dir <- function() { + opt <- getOption("commons.context_cache") + if (!is.null(opt) && !identical(opt, FALSE)) { + if (!rlang::is_string(opt)) { + cli::cli_abort( + "{.code options(commons.context_cache)} must be a path to a cache directory or {.code FALSE}." + ) + } + return(opt) + } + for (env in c("COMMONS_CONTEXT_CACHE", "CONNECT_CONTENT_DATA_DIR")) { + val <- Sys.getenv(env, unset = NA_character_) + false_like <- !is.na(val) && tolower(val) %in% c("false", "0", "no") + if (!is.na(val) && nzchar(val) && !false_like) { + return(val) + } + } + if (is_shiny_app()) { + app_dir <- shiny::getShinyOption("appDir") + if (!is.null(app_dir)) { + app_cache <- file.path(app_dir, "app_cache", "commons") + if ( + is_hosted_shiny_app() || + dir.exists(app_cache) || + dir.exists(dirname(app_cache)) + ) { + return(app_cache) + } + } + } + tools::R_user_dir("commons", "cache") +} + +is_shiny_app <- function() { + isNamespaceLoaded("shiny") && shiny::isRunning() +} + +# Connect and Shiny Server both set SHINY_SERVER_VERSION for content. +is_hosted_shiny_app <- function() { + nzchar(Sys.getenv("SHINY_SERVER_VERSION")) && is_shiny_app() +} + +# Caching must never take down the app: if the resolved cache dir can't be +# created or written, warn once and fall back to a per-session tempdir (the +# store becomes per-process, as if persistence were disabled). +context_cache_dir_safe <- function() { + dir <- context_cache_dir() + # Probe with an actual write: file.access() checks DOS attributes rather + # than ACLs on Windows, so it can report an unwritable dir as writable. + ok <- tryCatch( + { + dir.create(dir, recursive = TRUE, showWarnings = FALSE) + # tempfile() warns (not errors) when dir isn't a directory + dir.exists(dir) && { + probe <- tempfile(tmpdir = dir) + # file.create() warns rather than errors on failure + suppressWarnings(file.create(probe)) && unlink(probe) == 0 + } + }, + error = function(err) FALSE + ) + if (ok) { + return(dir) + } + if (is.null(context_cache_state$warned)) { + context_cache_state$warned <- TRUE + cli::cli_warn(c( + "Cannot write to the context cache directory {.path {dir}}.", + i = "Falling back to a per-session temporary directory; the context index will be rebuilt in each process." + )) + } + if (is.null(context_cache_state$fallback_dir)) { + context_cache_state$fallback_dir <- tempfile("commons-context-cache-") + dir.create(context_cache_state$fallback_dir, recursive = TRUE) } - state$store + context_cache_state$fallback_dir } context_search <- function(layer, query, n = 3) { diff --git a/pkg-r/R/data-source.R b/pkg-r/R/data-source.R index c1437429..992cb55f 100644 --- a/pkg-r/R/data-source.R +++ b/pkg-r/R/data-source.R @@ -12,13 +12,16 @@ #' * A `pins` board, e.g. [pins::board_connect()], is read into the same #' in-process database: each pin in `tables` becomes a table. Pin names are #' validated against the board at construction (a single listing call), but -#' each pin is downloaded only when its table is first used. -#' [commons_server()] starts a background process right after startup that -#' downloads the remaining pins into the local pins cache, so a first use -#' typically only reads an already-downloaded file. A table reflects the pin's -#' value at first use and is not refreshed for the lifetime of the data -#' source; if a pin can't be read (e.g. a network failure), the error surfaces -#' at that first use and the read is retried on the next one. +#' each pin is downloaded only when its table is first used. Calling the +#' agent's `prewarm()` method (see [commons_prewarm()]) starts a +#' background process that downloads the remaining pins into the local +#' pins cache, so a first use typically only reads an already-downloaded +#' file. Since the pins cache is on disk, `prewarm()` can also run +#' ahead of deployment to warm the cache the deployed app will read. A +#' table reflects the pin's value at first use and is not refreshed for +#' the lifetime of the data source; if a pin can't be read (e.g. a network +#' failure), the error surfaces at that first use and the read is retried +#' on the next one. #' #' @param ... A single DBI connection, a single `pins` board, or named data #' frames to register as tables. When passing data frames, each name becomes @@ -455,7 +458,7 @@ source_ensure_tables <- function(source, tables, call = rlang::caller_env()) { for (table in todo) { pin <- pending$pins[[table]] value <- tryCatch( - pins::pin_read(pending$board, pin), + with_pin_lock(pending$board, pin, pins::pin_read(pending$board, pin)), error = function(err) { cli::cli_abort( "Failed to read pin {.val {pin}} for table {.val {table}}.", @@ -484,6 +487,30 @@ source_ensure_all <- function(source, call = rlang::caller_env()) { source_ensure_tables(source, state$tables, call = call) } +# pins has no cache locking, so a background prewarm downloading a pin can +# race a first-use pin_read() of the same pin and leave a truncated cache +# entry. Both sides take an exclusive lock keyed by the board's cache path +# and pin name, so the reader waits out an in-flight download instead of +# duplicating it. +with_pin_lock <- function(board, pin, expr) { + # `cache` is a pins implementation detail (verified against pins 1.4.x); + # the guards below fail open to an unlocked read if it ever goes away. + cache <- board$cache + # Boards without a download cache (e.g. board_folder) never download, so + # there is no race to guard against. + if (is.null(cache) || is.na(cache) || !nzchar(cache)) { + return(force(expr)) + } + # Sanitized names can collide ("a/b" vs "a_b"), which merely serializes + # two pins on one lock. Lock files are never removed, but they're empty + # and there is at most one per pin. + name <- gsub("[^A-Za-z0-9._-]", "_", pin) + dir.create(cache, recursive = TRUE, showWarnings = FALSE) + lock <- filelock::lock(file.path(cache, paste0("commons-", name, ".lock"))) + on.exit(filelock::unlock(lock), add = TRUE) + force(expr) +} + # Warm the pins on-disk cache in a background process rather than loading into # DuckDB: dbWriteTable() must run in this process (where the DuckDB lives) and # would block every question asked during the load. The board is serialized to @@ -528,7 +555,7 @@ prewarm_downloads <- function(board, pins) { function(pin) { tryCatch( { - pins::pin_download(board, pin) + with_pin_lock(board, pin, pins::pin_download(board, pin)) TRUE }, error = function(err) FALSE diff --git a/pkg-r/man/commons_prewarm.Rd b/pkg-r/man/commons_prewarm.Rd new file mode 100644 index 00000000..c87e9a88 --- /dev/null +++ b/pkg-r/man/commons_prewarm.Rd @@ -0,0 +1,90 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/chat.R +\name{commons_prewarm} +\alias{commons_prewarm} +\title{Pre-warm a commons agent's caches ahead of deployment} +\usage{ +commons_prewarm(client, cache_dir) +} +\arguments{ +\item{client}{A \code{\link[=commons]{commons()}} agent.} + +\item{cache_dir}{A directory for the context index cache, used for this +call only (equivalent to setting +\code{options(commons.context_cache = cache_dir)} around it). Point it at +a directory inside the app so the warmed index ships with the +deployment. Note that rsconnect excludes \verb{app_cache/} from deployed +bundles, so pick another name.} +} +\value{ +\code{NULL}, invisibly. +} +\description{ +A \code{\link[=commons]{commons()}} agent does some expensive setup the first time it needs to: +building the search index over the context layer and downloading any +uncached pins (see \code{\link[=data_source]{data_source()}}). When you serve the agent with +\code{\link[=commons_server]{commons_server()}} or \code{\link[=commons_app]{commons_app()}}, this warming already happens +automatically during post-startup idle time, so the first question is +(hopefully) fast — you (hopefully) don't need to call +\code{commons_prewarm()} yourself. +} +\details{ +The reason to call it directly is to warm the caches \emph{without} running +the app. Both kinds of setup are cached on disk — the context index is +built once per version of your context documents, and pins are +downloaded once into the local pins cache — so running +\code{commons_prewarm(agent, cache_dir)} in a script before deploying lets +the deployed app start warm. + +Pre-warming is a pure optimization — anything it builds is rebuilt on +demand if it's missing — so failures are reported as warnings rather +than errors. If a pre-deploy script should fail the deploy when warming +fails, call the agent's \code{prewarm()} method directly instead; it lets +errors propagate. +} +\section{Cache configuration}{ + +You can usually ignore this section: by default the context index cache +lives in a per-user directory that does the right thing locally and on +most hosted platforms. The reasons to configure it are: +\itemize{ +\item \strong{Persistence across deployments on ephemeral hosts.} This is +already handled on Connect and Shiny Server: the cache automatically +lands in Connect's persistent data directory when the server provides +one (currently an early-access feature the administrator enables), or +in an \verb{app_cache/} directory beside the app, which survives +redeploys. But on hosts where no local disk persists (e.g. Connect +Cloud, which resets disk to the deployed bundle and never sets a data +directory), the cache is rebuilt after every redeploy unless you +point it at persistent storage yourself. +\item \strong{Shipping a warm cache with the app.} Run +\code{commons_prewarm(agent, cache_dir = "path/inside/the/app")} before +deploying, and the deployed bundle includes the pre-built index. +\code{cache_dir} is required for this reason — anything resolved +implicitly (a per-user cache directory) would not ship with the +deployment. +\item \strong{Development loops.} If you're editing context documents and want +each change re-indexed from scratch, disable persistence with +\code{options(commons.context_cache = FALSE)}. +} + +Set the directory with \code{options(commons.context_cache = "path/to/dir")} +or the \code{COMMONS_CONTEXT_CACHE} environment variable. The cache is +capped at 256 MB with least-recently-used eviction; raise +\code{options(commons.context_cache_max_size)} (in bytes) if you index very +large context. +} + +\examples{ +\dontrun{ +# In a pre-deploy script: warm the caches into a directory inside the +# app, so the deployed bundle includes the pre-built context index +agent <- commons( + ellmer::chat_anthropic(), + data_sources = data_source(sales = sales) +) +# (not app_cache/, which rsconnect excludes from the bundle) +commons_prewarm(agent, cache_dir = "commons-cache") +} + +} diff --git a/pkg-r/man/data_source.Rd b/pkg-r/man/data_source.Rd index ad45e24d..933945be 100644 --- a/pkg-r/man/data_source.Rd +++ b/pkg-r/man/data_source.Rd @@ -61,13 +61,16 @@ when the data isn't already in a database. \item A \code{pins} board, e.g. \code{\link[pins:board_connect]{pins::board_connect()}}, is read into the same in-process database: each pin in \code{tables} becomes a table. Pin names are validated against the board at construction (a single listing call), but -each pin is downloaded only when its table is first used. -\code{\link[=commons_server]{commons_server()}} starts a background process right after startup that -downloads the remaining pins into the local pins cache, so a first use -typically only reads an already-downloaded file. A table reflects the pin's -value at first use and is not refreshed for the lifetime of the data -source; if a pin can't be read (e.g. a network failure), the error surfaces -at that first use and the read is retried on the next one. +each pin is downloaded only when its table is first used. Calling the +agent's \code{prewarm()} method (see \code{\link[=commons_prewarm]{commons_prewarm()}}) starts a +background process that downloads the remaining pins into the local +pins cache, so a first use typically only reads an already-downloaded +file. Since the pins cache is on disk, \code{prewarm()} can also run +ahead of deployment to warm the cache the deployed app will read. A +table reflects the pin's value at first use and is not refreshed for +the lifetime of the data source; if a pin can't be read (e.g. a network +failure), the error surfaces at that first use and the read is retried +on the next one. } } \section{Data dictionaries}{ diff --git a/pkg-r/tests/testthat/_snaps/layer-objects.md b/pkg-r/tests/testthat/_snaps/layer-objects.md index a3755fc8..af340f03 100644 --- a/pkg-r/tests/testthat/_snaps/layer-objects.md +++ b/pkg-r/tests/testthat/_snaps/layer-objects.md @@ -36,3 +36,4 @@ print(context_two) Message A commons context layer with 2 documents. + diff --git a/pkg-r/tests/testthat/setup-context-cache.R b/pkg-r/tests/testthat/setup-context-cache.R new file mode 100644 index 00000000..79e222f7 --- /dev/null +++ b/pkg-r/tests/testthat/setup-context-cache.R @@ -0,0 +1,6 @@ +# Isolate the persistent context store cache per test session. +options(commons.context_cache = tempfile("commons-context-cache-")) +withr::defer( + unlink(getOption("commons.context_cache"), recursive = TRUE), + testthat::teardown_env() +) diff --git a/pkg-r/tests/testthat/test-chat.R b/pkg-r/tests/testthat/test-chat.R index 8d67dcab..c266d1a6 100644 --- a/pkg-r/tests/testthat/test-chat.R +++ b/pkg-r/tests/testthat/test-chat.R @@ -105,3 +105,84 @@ test_that("commons_server requires a commons agent", { error = TRUE ) }) + +test_that("commons_prewarm() downgrades prewarm failures to warnings", { + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + agent <- test_agent(context_layer = context_layer(files = path)) + + local_mocked_bindings( + context_store = function(...) stop("index build exploded"), + .package = "commons" + ) + expect_warning( + commons_prewarm(agent, cache_dir = withr::local_tempdir()), + "index build exploded" + ) +}) + +test_that("commons_prewarm() warns on failures with braces in the message", { + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + agent <- test_agent(context_layer = context_layer(files = path)) + + # DuckDB errors embed JSON; cli must not interpolate the raw message + local_mocked_bindings( + context_store = function(...) stop('bad store: {"code": 1}'), + .package = "commons" + ) + expect_warning( + commons_prewarm(agent, cache_dir = withr::local_tempdir()), + "bad store", + fixed = TRUE + ) +}) + +test_that("commons_prewarm(cache_dir =) builds the store in that directory", { + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + agent <- test_agent(context_layer = context_layer(files = path)) + + cache_dir <- withr::local_tempdir() + expect_message( + commons_prewarm(agent, cache_dir = cache_dir), + "Warmed the context index cache" + ) + expect_length(list.files(file.path(cache_dir, "context")), 1) +}) + +test_that("commons_prewarm() warns when there is nothing to cache", { + expect_warning( + commons_prewarm(test_agent(), cache_dir = withr::local_tempdir()), + "No context index was cached" + ) +}) + +test_that("commons_prewarm() validates cache_dir", { + expect_error( + commons_prewarm(test_agent(), cache_dir = TRUE), + "must be a path" + ) + expect_error( + commons_prewarm(test_agent()), + "cache_dir.* is required" + ) +}) + + +test_that("commons_app() prewarms the agent on idle", { + skip_if_not_installed("shiny") + skip_if_not_installed("shinychat") + + app <- commons_app(test_agent()) + app_env <- environment(app$serverFuncSource) + prewarmed <- FALSE + testthat::local_mocked_bindings( + prewarm_on_idle = function(client) prewarmed <<- TRUE, + .package = "commons" + ) + shiny::testServer(app_env$server, { + session$flushReact() + }) + expect_true(prewarmed) +}) diff --git a/pkg-r/tests/testthat/test-commons.R b/pkg-r/tests/testthat/test-commons.R index fb2e4b6d..22546238 100644 --- a/pkg-r/tests/testthat/test-commons.R +++ b/pkg-r/tests/testthat/test-commons.R @@ -437,8 +437,22 @@ test_that("prewarm() without a context layer is a no-op", { expect_no_error(test_agent()$prewarm()) }) +test_that("prewarm() propagates failures", { + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + agent <- test_agent(context_layer = context_layer(files = path)) + + local_mocked_bindings( + context_store = function(...) stop("index build exploded"), + .package = "commons" + ) + expect_error(agent$prewarm(), "index build exploded") +}) + test_that("prewarm() records a cache-miss build and its own span", { skip_if_not_installed("otelsdk") + # A fresh cache root guarantees a cold build regardless of test order. + withr::local_options(commons.context_cache = withr::local_tempdir()) path <- withr::local_tempfile(fileext = ".md") writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) @@ -458,6 +472,7 @@ test_that("prewarm() records a cache-miss build and its own span", { test_that("prewarm() records a cache hit without a build span", { skip_if_not_installed("otelsdk") + withr::local_options(commons.context_cache = withr::local_tempdir()) path <- withr::local_tempfile(fileext = ".md") writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) diff --git a/pkg-r/tests/testthat/test-context-layer.R b/pkg-r/tests/testthat/test-context-layer.R index d4f2019b..83f3d2d8 100644 --- a/pkg-r/tests/testthat/test-context-layer.R +++ b/pkg-r/tests/testthat/test-context-layer.R @@ -77,3 +77,258 @@ test_that("context_layer skips a frontmatter-only file", { layer <- context_layer(files = path) expect_length(context_search(layer, "provenance"), 0) }) + +test_that("the context store persists on disk and is shared across layers", { + withr::local_options(commons.context_cache = withr::local_tempdir()) + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + + layer1 <- context_layer(files = path) + store_path <- context_store_path(context_layer_state(layer1)$docs) + expect_false(file.exists(store_path)) + + expect_match(context_search(layer1, "revenue")[[1]], "booked") + expect_true(file.exists(store_path)) + + + layer2 <- context_layer(files = path) + expect_identical( + context_store_path(context_layer_state(layer2)$docs), + store_path + ) + expect_match(context_search(layer2, "revenue")[[1]], "booked") + + + expect_false(identical(context_store_path("other docs"), store_path)) +}) + +test_that("commons.context_cache = FALSE builds the store in memory", { + withr::local_options(commons.context_cache = FALSE) + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + + layer <- context_layer(files = path) + expect_match(context_search(layer, "revenue")[[1]], "booked") + expect_identical( + DBI::dbGetInfo(context_layer_state(layer)$store@con)$dbname, + ":memory:" + ) +}) + +test_that("an unwritable cache dir warns once and falls back to a tempdir", { + # A file where the cache dir should be makes dir.create() fail. + blocker <- withr::local_tempfile() + writeLines("occupied", blocker) + withr::local_options(commons.context_cache = file.path(blocker, "cache")) + + context_cache_state$warned <- NULL + context_cache_state$fallback_dir <- NULL + + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + layer <- context_layer(files = path) + + expect_warning( + expect_match(context_search(layer, "revenue")[[1]], "booked"), + "Falling back to a per-session temporary directory" + ) + + expect_no_warning(context_cache_dir_safe()) + expect_identical(context_cache_dir_safe(), context_cache_state$fallback_dir) +}) + +test_that("prune_context_cache() is throttled across builds", { + dir <- withr::local_tempdir() + stale <- file.path(dir, "stale.duckdb") + writeLines(strrep("x", 1000), stale) + + + context_cache_state$n_builds <- 1 + context_cache_state$last_prune <- Sys.time() + prune_context_cache(dir, max_size = 1) + expect_true(file.exists(stale)) + + + context_cache_state$n_builds <- 19 + prune_context_cache(dir, max_size = 1) + expect_false(file.exists(stale)) +}) + +test_that("prune_context_cache() evicts least-recently-used stores over the size cap", { + dir <- withr::local_tempdir() + oldest <- file.path(dir, "oldest.duckdb") + middle <- file.path(dir, "middle.duckdb") + newest <- file.path(dir, "newest.duckdb") + for (f in c(oldest, middle, newest)) { + writeLines(strrep("x", 1000), f) + } + now <- Sys.time() + Sys.setFileTime(oldest, now - 300) + Sys.setFileTime(middle, now - 200) + Sys.setFileTime(newest, now - 100) + + context_cache_state$n_builds <- 0 + context_cache_state$last_prune <- NULL + + cap <- 2 * file.size(newest) + prune_context_cache(dir, max_size = cap) + + expect_false(file.exists(oldest)) + expect_true(file.exists(middle)) + expect_true(file.exists(newest)) +}) + +test_that("an unwritable cache dir surfaces only the once-per-session warning", { + skip_on_os("windows") # Sys.chmod() read-only bits aren't enforced there + skip_if(.Platform$OS.type == "unix" && Sys.info()[["user"]] == "root") + + # The cache dir itself is unwritable, so the probe's file.create() fails + readonly <- withr::local_tempdir() + withr::defer(Sys.chmod(readonly, mode = "0755")) + Sys.chmod(readonly, mode = "0555") + withr::local_options(commons.context_cache = readonly) + + context_cache_state$warned <- NULL + context_cache_state$fallback_dir <- NULL + + + warnings <- character() + withCallingHandlers( + context_cache_dir_safe(), + warning = function(w) { + warnings <<- c(warnings, conditionMessage(w)) + invokeRestart("muffleWarning") + } + ) + expect_length(warnings, 1) + expect_match(warnings, "Falling back to a per-session temporary directory") + + warnings <- character() + withCallingHandlers( + context_cache_dir_safe(), + warning = function(w) { + warnings <<- c(warnings, conditionMessage(w)) + invokeRestart("muffleWarning") + } + ) + expect_length(warnings, 0) +}) + +test_that("prune_context_cache() tolerates stores vanishing mid-prune", { + dir <- withr::local_tempdir() + store <- file.path(dir, "store.duckdb") + writeLines(strrep("x", 1000), store) + + context_cache_state$n_builds <- 0 + context_cache_state$last_prune <- NULL + + # A broken symlink is listed but stats as NA, like a store a concurrent + # pruner deleted mid-prune + skip_on_os("windows") # symlinks need elevated privileges there + ghost <- file.path(dir, "ghost.duckdb") + file.symlink(file.path(dir, "no-such-target"), ghost) + + context_cache_state$warned_size <- NULL + expect_no_warning( + expect_no_error(prune_context_cache(dir, max_size = file.size(store))) + ) + expect_true(file.exists(store)) +}) + +test_that("prune_context_cache() keeps a single store larger than the cap", { + dir <- withr::local_tempdir() + big <- file.path(dir, "big.duckdb") + writeLines(strrep("x", 10000), big) + + context_cache_state$n_builds <- 0 + context_cache_state$last_prune <- NULL + context_cache_state$warned_size <- NULL + + expect_warning( + prune_context_cache(dir, max_size = 1, protect = big), + "exceeds its size cap" + ) + expect_true(file.exists(big)) + + + context_cache_state$n_builds <- 0 + context_cache_state$last_prune <- NULL + expect_no_warning( + prune_context_cache(dir, max_size = 1, protect = big) + ) + expect_true(file.exists(big)) +}) + +test_that("prune_context_cache() reaps stale .build-* temp files only", { + dir <- withr::local_tempdir() + old <- file.path(dir, ".build-old") + fresh <- file.path(dir, ".build-fresh") + store <- file.path(dir, "store.duckdb") + for (f in c(old, fresh, store)) { + writeLines("x", f) + } + Sys.setFileTime(old, Sys.time() - 25 * 60 * 60) + + context_cache_state$n_builds <- 0 + context_cache_state$last_prune <- NULL + prune_context_cache(dir) + + expect_false(file.exists(old)) + expect_true(file.exists(fresh)) + expect_true(file.exists(store)) +}) + +test_that("context_store() warns and rebuilds when the cached store won't open", { + layer <- new_context_layer(c("Some context about widgets.")) + path <- context_store_path(context_layer_state(layer)$docs) + dir.create(dirname(path), recursive = TRUE, showWarnings = FALSE) + # Stand in for a store unlinked or corrupted between file.exists() and + # connect (e.g. by a concurrent pruner) + writeLines("not a duckdb file", path) + + expect_warning( + store <- context_store(layer), + "rebuilding" + ) + expect_identical(store, context_layer_state(layer)$store) + expect_equal( + context_search(layer, "widgets"), + "Some context about widgets." + ) +}) + +test_that("cache root prefers the option, then env vars", { + withr::local_options(commons.context_cache = NULL) + withr::local_envvar( + COMMONS_CONTEXT_CACHE = NA, + CONNECT_CONTENT_DATA_DIR = NA, + SHINY_SERVER_VERSION = NA + ) + expect_identical( + context_cache_dir(), + tools::R_user_dir("commons", "cache") + ) + + withr::local_envvar(CONNECT_CONTENT_DATA_DIR = "/connect/data") + expect_identical(context_cache_dir(), "/connect/data") + + withr::local_envvar(COMMONS_CONTEXT_CACHE = "/explicit/cache") + expect_identical(context_cache_dir(), "/explicit/cache") + + withr::local_options(commons.context_cache = "/option/cache") + expect_identical(context_cache_dir(), "/option/cache") +}) + +test_that("the context_cache option must be a path or FALSE", { + withr::local_options(commons.context_cache = TRUE) + expect_error(context_cache_dir(), "must be a path") +}) + +test_that("COMMONS_CONTEXT_CACHE can disable the cache", { + withr::local_options(commons.context_cache = NULL) + withr::local_envvar(COMMONS_CONTEXT_CACHE = "FALSE") + expect_false(context_cache_enabled()) + + withr::local_envvar(CONNECT_CONTENT_DATA_DIR = "/connect/data") + expect_identical(context_cache_dir(), "/connect/data") +}) diff --git a/pkg-r/vignettes/commons.Rmd b/pkg-r/vignettes/commons.Rmd index 56bf6e2f..661a9a5a 100644 --- a/pkg-r/vignettes/commons.Rmd +++ b/pkg-r/vignettes/commons.Rmd @@ -318,4 +318,4 @@ agent <- commons( commons_app(agent) ``` -Use `commons_app()` to run the agent in a local or single-user Shiny app. For multi-user deployments, compose shinychat's UI with `commons_theme()` on the page and `commons_server()` in the server, and create a new agent for each Shiny session. This example assumes that `observations` and `site_area` are data frames loaded when the app starts. +Use `commons_app()` to run the agent in a local or single-user Shiny app. For multi-user deployments, compose shinychat's UI with `commons_theme()` on the page and `commons_server()` in the server, and create a new agent for each Shiny session. `commons_server()` warms the agent during post-startup idle time; `agent$prewarm()` (see `commons_prewarm()`) warms the context index and pins cache ahead of deployment. This example assumes that `observations` and `site_area` are data frames loaded when the app starts.