From 19bbb24572fa7ed6f461c45d767480bd50eeb186 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 28 Aug 2026 14:59:35 -0500 Subject: [PATCH 1/3] Query parquet/csv pins in place via DuckDB views First use of a pinned table used to pin_read() the data into R and dbWriteTable() a copy into DuckDB. For the formats DuckDB reads natively with built-in table functions (parquet, csv), first use now registers a view over the pin's versioned file instead: no copy, no R round-trip, and queries get column and predicate pushdown. The view references the resolved version's path, so it is as stable as the version itself. The locked-down connection gains an allowed_directories exception scoped to exactly the board's pin root (its cache for remote boards, its directory for folder boards), set before external access is disabled -- pin files become the only files the agent's queries can read. Extension readers (e.g. read_json_auto) stay off the view path because autoload is disabled; json/rds/qs2/arrow pins keep the eager path. A view dangles if its file disappears after registration (a rewrite on a non-versioned board deletes the old version's directory, or the cache is pruned). source_query()'s error-driven retry loop and source_describe()'s sample now re-resolve the latest version and re-register -- view again, or an eager load if the format changed -- before surfacing an error. --- pkg-r/R/data-source.R | 232 +++++++++++++++++++----- pkg-r/man/data_source.Rd | 25 ++- pkg-r/tests/testthat/test-data-source.R | 66 +++++++ 3 files changed, 272 insertions(+), 51 deletions(-) diff --git a/pkg-r/R/data-source.R b/pkg-r/R/data-source.R index c168621a..544156fd 100644 --- a/pkg-r/R/data-source.R +++ b/pkg-r/R/data-source.R @@ -12,16 +12,21 @@ #' * 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. Calling the -#' agent's `prewarm_sources()` 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_sources()` 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. +#' each pin is downloaded only when its table is first used. Parquet and +#' CSV pins are then queried in place -- a DuckDB view over the downloaded +#' file, with no copy into the database -- while other formats (e.g. RDS) +#' are read and loaded at first use. Calling the agent's +#' `prewarm_sources()` 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_sources()` can also run ahead of +#' deployment to warm the cache the deployed app will read. A table +#' reflects the pin version resolved at first use and is not refreshed for +#' the lifetime of the data source; if the board deletes that version +#' (e.g. a rewrite on a non-versioned board), the next query re-resolves +#' the latest version. 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 @@ -333,9 +338,12 @@ data_source_board <- function( # Lock the connection down before any writes; lock_configuration() only # freezes SET statements, so later dbWriteTable() from a deferred read still - # works. + # works. Views over pin files read straight from the board's directory + # (folder boards) or local cache (remote boards), so allowlist exactly that + # root: the connection can read pin files and nothing else. + root <- board_pin_root(board) con <- duckdb_connect() - duckdb_lock_down(con) + duckdb_lock_down(con, allow_dirs = root) check_labels_free(con, names(tables), call = call) new_data_source( @@ -343,19 +351,36 @@ data_source_board <- function( names(tables), owned = TRUE, dictionary = dictionary, - pending = new_pending_pins(board, tables) + pending = new_pending_pins(board, tables, root) ) } +# The directory pin files for this board live under: the local cache for +# remote boards (connect, s3, ...), the board's own directory for folder +# boards. NULL when the board's layout is unknown, which disables the +# zero-copy view path (pins load eagerly instead). +board_pin_root <- function(board) { + for (field in c("cache", "path")) { + val <- board[[field]] + if (!is.null(val) && !is.na(val) && nzchar(val)) { + return(val) + } + } + NULL +} + # The deferred-read state a board source carries: the board plus the pins not # yet loaded (named character: table label -> pin name). Shared by every alias # of the source, so a read through one is seen by all. source_prewarm() also # stores its background downloader's handle here ($process), so all aliases see # at most one live warmer. -new_pending_pins <- function(board, tables) { +new_pending_pins <- function(board, tables, root = NULL) { pending <- new.env(parent = emptyenv()) pending$board <- board pending$pins <- tables + pending$root <- root + # Tables registered as views over pin files: table label -> list(pin, path). + pending$views <- list() pending } @@ -457,9 +482,16 @@ source_ensure_tables <- function(source, tables, call = rlang::caller_env()) { ) for (table in todo) { pin <- pending$pins[[table]] - value <- tryCatch( - with_pin_lock(pending$board, pin, pins::pin_read(pending$board, pin)), + tryCatch( + with_pin_lock( + pending$board, + pin, + source_load_pin(source, table, pin, call = call) + ), error = function(err) { + if (inherits(err, "commons_pin_not_data_frame")) { + stop(err) + } cli::cli_abort( "Failed to read pin {.val {pin}} for table {.val {table}}.", parent = err, @@ -467,21 +499,109 @@ source_ensure_tables <- function(source, tables, call = rlang::caller_env()) { ) } ) - if (!is.data.frame(value)) { - cli::cli_abort( - c( - "Pin {.val {pin}} for table {.val {table}} is not a data frame.", - i = "It is {.obj_type_friendly {value}}." - ), - call = call - ) - } - DBI::dbWriteTable(state$con, table, as.data.frame(value), overwrite = TRUE) pending$pins <- pending$pins[setdiff(names(pending$pins), table)] } invisible(source) } +# Register a pinned table in DuckDB. When the pin is a single file DuckDB +# reads natively (parquet/csv/json), register a view over the downloaded +# file instead of copying the data in: the load is free, the data stays in +# the pins cache, and queries get DuckDB's column and predicate pushdown. +# The view references the resolved version's file, so it is as stable as +# that version; refresh_dangling_views() covers boards that delete old +# versions. Other formats (rds, qs2, arrow, uploads) take the eager path. +source_load_pin <- function(source, table, pin, call = rlang::caller_env()) { + state <- data_source_state(source) + board <- state$pending$board + meta <- pins::pin_meta(board, pin) + version <- meta$local$version + path <- pins::pin_download(board, pin, version = version) + + reader <- if (length(path) == 1 && !is.null(state$pending$root)) { + pin_view_reader(meta$type) + } + if (!is.null(reader)) { + create_pin_view(state$con, table, path, reader) + state$pending$views[[table]] <- list(pin = pin, path = path) + return(invisible(source)) + } + + value <- pins::pin_read(board, pin, version = version) + if (!is.data.frame(value)) { + cli::cli_abort( + c( + "Pin {.val {pin}} for table {.val {table}} is not a data frame.", + i = "It is {.obj_type_friendly {value}}." + ), + class = "commons_pin_not_data_frame", + call = call + ) + } + # A previous registration may have been a view (e.g. the pin's format + # changed across versions); dbWriteTable can't overwrite a view. + DBI::dbExecute( + state$con, + sprintf("DROP VIEW IF EXISTS %s", DBI::dbQuoteIdentifier(state$con, table)) + ) + DBI::dbWriteTable(state$con, table, as.data.frame(value), overwrite = TRUE) + invisible(source) +} + +# Only formats DuckDB reads with built-in (non-extension) table functions: +# the connection is locked down with extension autoload disabled, so +# extension-backed readers (e.g. read_json_auto) are unavailable. +pin_view_reader <- function(type) { + switch( + type, + parquet = "read_parquet", + csv = "read_csv_auto" + ) +} + +create_pin_view <- function(con, table, path, reader) { + DBI::dbExecute( + con, + sprintf( + "CREATE OR REPLACE VIEW %s AS SELECT * FROM %s(%s)", + DBI::dbQuoteIdentifier(con, table), + reader, + DBI::dbQuoteString(con, path) + ) + ) +} + +# A view over a pin file dangles when the file disappears after registration: +# a rewrite on a non-versioned board deletes the old version's directory, and +# cache pruning can remove files too. Re-resolve the pin's latest version and +# register it afresh (a view again, or an eager load if the format changed). +# Returns TRUE only when a view was actually refreshed, so callers can retry +# the failed query once without risking an unbounded loop. +refresh_dangling_views <- function(source) { + pending <- data_source_state(source)$pending + if (is.null(pending) || length(pending$views) == 0) { + return(FALSE) + } + refreshed <- FALSE + for (table in names(pending$views)) { + view <- pending$views[[table]] + if (file.exists(view$path)) { + next + } + tryCatch( + { + with_pin_lock(pending$board, view$pin, { + pending$views[[table]] <- NULL + source_load_pin(source, table, view$pin) + }) + refreshed <- TRUE + }, + error = function(err) NULL + ) + } + refreshed +} + source_ensure_all <- function(source, call = rlang::caller_env()) { state <- data_source_state(source) source_ensure_tables(source, state$tables, call = call) @@ -617,13 +737,20 @@ source_describe <- function( catalog_ensure_queryable(source, table, call = call) source_ensure_tables(source, table) - sample <- DBI::dbGetQuery( - state$con, - sprintf( - "SELECT * FROM %s LIMIT %d", - DBI::dbQuoteIdentifier(state$con, id), - n_sample - ) + sample_sql <- sprintf( + "SELECT * FROM %s LIMIT %d", + DBI::dbQuoteIdentifier(state$con, id), + n_sample + ) + sample <- tryCatch( + DBI::dbGetQuery(state$con, sample_sql), + error = function(err) { + if (refresh_dangling_views(source)) { + DBI::dbGetQuery(state$con, sample_sql) + } else { + stop(err) + } + } ) relation <- source_relation(source, table) if (is.null(state$relations)) { @@ -721,10 +848,14 @@ source_query <- function(source, sql) { return(result) } todo <- pending_tables_in_error(source, result) - if (length(todo) == 0) { - stop(result) + if (length(todo) > 0) { + source_ensure_tables(source, todo) + next } - source_ensure_tables(source, todo) + if (refresh_dangling_views(source)) { + next + } + stop(result) } } @@ -803,18 +934,37 @@ check_query <- function(sql, call = rlang::caller_env()) { # DuckDB-specific hardening for the connection we own: no extension loading, # no filesystem or external access, and the configuration locked thereafter. -duckdb_lock_down <- function(con) { +duckdb_lock_down <- function(con, allow_dirs = character(0)) { + # allowed_directories must be set while external access is still enabled, + # and is incompatible with disabling LocalFileSystem (the filesystem-level + # block wins over the path allowlist). With an allowlist, external access + # stays disabled and only the listed directories remain readable. + if (length(allow_dirs) > 0) { + DBI::dbExecute( + con, + sprintf( + "SET allowed_directories = [%s]", + paste(DBI::dbQuoteString(con, allow_dirs), collapse = ", ") + ) + ) + } DBI::dbExecute( con, - " + sprintf( + " SET allow_community_extensions = false; SET allow_unsigned_extensions = false; SET autoinstall_known_extensions = false; SET autoload_known_extensions = false; SET enable_external_access = false; -SET disabled_filesystems = 'LocalFileSystem'; -SET lock_configuration = true; -" +%sSET lock_configuration = true; +", + if (length(allow_dirs) == 0) { + "SET disabled_filesystems = 'LocalFileSystem';\n" + } else { + "" + } + ) ) invisible(con) } diff --git a/pkg-r/man/data_source.Rd b/pkg-r/man/data_source.Rd index 390bfc6e..2fbbdfa1 100644 --- a/pkg-r/man/data_source.Rd +++ b/pkg-r/man/data_source.Rd @@ -61,16 +61,21 @@ 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. Calling the -agent's \code{prewarm_sources()} 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_sources()} 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. +each pin is downloaded only when its table is first used. Parquet and +CSV pins are then queried in place -- a DuckDB view over the downloaded +file, with no copy into the database -- while other formats (e.g. RDS) +are read and loaded at first use. Calling the agent's +\code{prewarm_sources()} 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_sources()} can also run ahead of +deployment to warm the cache the deployed app will read. A table +reflects the pin version resolved at first use and is not refreshed for +the lifetime of the data source; if the board deletes that version +(e.g. a rewrite on a non-versioned board), the next query re-resolves +the latest version. 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/test-data-source.R b/pkg-r/tests/testthat/test-data-source.R index 361a81a7..e3dbfbf9 100644 --- a/pkg-r/tests/testthat/test-data-source.R +++ b/pkg-r/tests/testthat/test-data-source.R @@ -498,3 +498,69 @@ test_that("as_data_sources validates its input", { error = TRUE ) }) + +test_that("parquet/csv pins register as zero-copy views over the pins cache", { + skip_if_not_installed("pins") + + board <- pins::board_temp() + suppressMessages({ + pins::pin_write(board, data.frame(id = 1:3, v = letters[1:3]), "p-parquet", type = "parquet") + pins::pin_write(board, data.frame(id = 4:6, v = letters[4:6]), "p-csv", type = "csv") + }) + src <- data_source( + board, + tables = c(parquet_t = "p-parquet", csv_t = "p-csv") + ) + + for (table in c("parquet_t", "csv_t")) { + res <- source_query(src, sprintf("SELECT * FROM %s", table)) + expect_equal(nrow(res), 3) + # Registered as a view over the cache file, not a copied table + catalog <- DBI::dbGetQuery( + src$con, + "SELECT table_type FROM information_schema.tables WHERE table_name = $1", + params = list(table) + ) + expect_equal(catalog$table_type, "VIEW") + # The view reads the pin's versioned file directly + expect_true(file.exists(src$pending$views[[table]]$path)) + } +}) + +test_that("rds pins still load eagerly as tables", { + skip_if_not_installed("pins") + + board <- board_with_pins("team-orders" = data.frame(id = 1:3)) + src <- data_source(board, tables = c(orders = "team-orders")) + + res <- source_query(src, "SELECT * FROM orders") + expect_equal(nrow(res), 3) + catalog <- DBI::dbGetQuery( + src$con, + "SELECT table_type FROM information_schema.tables WHERE table_name = 'orders'" + ) + expect_equal(catalog$table_type, "BASE TABLE") +}) + +test_that("a dangling pin view is re-resolved and retried transparently", { + skip_if_not_installed("pins") + + board <- pins::board_folder(withr::local_tempdir(), versioned = FALSE) + suppressMessages( + pins::pin_write(board, data.frame(id = 1L), "orders", type = "parquet") + ) + src <- data_source(board, tables = c(orders = "orders")) + + expect_equal(source_query(src, "SELECT * FROM orders")$id, 1L) + view_path <- src$pending$views$orders$path + + # A rewrite on a non-versioned board deletes the old version's directory + suppressMessages( + pins::pin_write(board, data.frame(id = 1:2), "orders", type = "parquet") + ) + expect_false(file.exists(view_path)) + + # The next query re-resolves the latest version and succeeds + expect_equal(source_query(src, "SELECT * FROM orders")$id, 1:2) + expect_true(file.exists(src$pending$views$orders$path)) +}) From 537aef72b4d68b56165f25e99f30878f2dfb12f4 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 31 Aug 2026 18:22:50 -0500 Subject: [PATCH 2/3] Review fixes: side-effect-free cache probing, test hygiene - The cache_hit span attribute in prewarm_context() probes the store path with the side-effect-free context_cache_dir() resolver (context_store_path() gains an injectable cache_dir), so recording telemetry cannot create directories or trigger the fallback warning. - Add local_context_cache_state() to snapshot/restore the package-level cache housekeeping state in tests that poke it. - Fix view tests to reach connection/pending state via data_source_state() (sources are R6 since #228). - Document the deliberate cache-before-path field order in board_pin_root(). --- pkg-r/R/commons.R | 10 +++++++--- pkg-r/R/context-layer.R | 7 +++++-- pkg-r/R/data-source.R | 9 +++++++-- pkg-r/tests/testthat/test-context-layer.R | 18 ++++++++++++++++++ pkg-r/tests/testthat/test-data-source.R | 11 ++++++----- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/pkg-r/R/commons.R b/pkg-r/R/commons.R index a772f689..17769129 100644 --- a/pkg-r/R/commons.R +++ b/pkg-r/R/commons.R @@ -409,13 +409,17 @@ Commons <- R6::R6Class( "commons_context_prewarm", attributes = list( "commons.context.n_docs" = length(layer_state$docs), - # tryCatch: telemetry must not abort prewarming (resolving the - # cache dir can fail or warn on an unwritable root). + # Probe with the side-effect-free resolver so recording telemetry + # can't create directories or trigger the fallback warning, and + # tryCatch so a resolution failure can't abort prewarming. "commons.context.cache_hit" = !is.null(layer_state$store) || isTRUE(tryCatch( context_cache_enabled() && - file.exists(context_store_path(layer_state$docs)), + file.exists(context_store_path( + layer_state$docs, + cache_dir = context_cache_dir() + )), error = function(err) FALSE )) ) diff --git a/pkg-r/R/context-layer.R b/pkg-r/R/context-layer.R index 9a5c1406..4d34b526 100644 --- a/pkg-r/R/context-layer.R +++ b/pkg-r/R/context-layer.R @@ -321,13 +321,16 @@ format_size <- function(bytes) { } } -context_store_path <- function(docs) { +# `cache_dir` is injectable so read-only probes (e.g. the cache_hit span +# attribute in prewarm_context()) can pass context_cache_dir() and avoid the +# fallback tempdir's side effects (directory creation, one-time warning). +context_store_path <- function(docs, cache_dir = context_cache_dir_safe()) { key <- rlang::hash(c( docs, paste0("ragnar:", utils::packageVersion("ragnar")), paste0("duckdb:", utils::packageVersion("duckdb")) )) - file.path(context_cache_dir_safe(), "context", paste0(key, ".duckdb")) + file.path(cache_dir, "context", paste0(key, ".duckdb")) } # Cache root resolution: an explicit override, then Connect's persistent diff --git a/pkg-r/R/data-source.R b/pkg-r/R/data-source.R index 544156fd..a4dda4c7 100644 --- a/pkg-r/R/data-source.R +++ b/pkg-r/R/data-source.R @@ -358,7 +358,9 @@ data_source_board <- function( # The directory pin files for this board live under: the local cache for # remote boards (connect, s3, ...), the board's own directory for folder # boards. NULL when the board's layout is unknown, which disables the -# zero-copy view path (pins load eagerly instead). +# zero-copy view path (pins load eagerly instead). The field order matters: +# `cache` wins because remote boards keep downloaded files there, while +# folder boards have no cache and only set `path`. board_pin_root <- function(board) { for (field in c("cache", "path")) { val <- board[[field]] @@ -611,7 +613,10 @@ source_ensure_all <- function(source, call = rlang::caller_env()) { # race a first-use pin_read() of the same pin and leave a truncated cache # entry that poisons later reads. Both sides take an exclusive lock keyed by # the board's cache path and pin name, making the cache single-writer: the -# reader waits out an in-flight download instead of duplicating it. +# reader waits out an in-flight download instead of duplicating it. Lock +# files are left in the cache after unlock: unlinking one while another +# process waits on it would break the mutual exclusion, and they cost one +# tiny file per pin. 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. diff --git a/pkg-r/tests/testthat/test-context-layer.R b/pkg-r/tests/testthat/test-context-layer.R index 9ca5394d..a13eea2a 100644 --- a/pkg-r/tests/testthat/test-context-layer.R +++ b/pkg-r/tests/testthat/test-context-layer.R @@ -115,12 +115,26 @@ test_that("commons.context_cache = FALSE builds the store in memory", { ) }) +# Reset the package-level cache housekeeping state on test exit, so tests +# that poke it don't leak order-dependence into later tests. +local_context_cache_state <- function(env = parent.frame()) { + old <- as.list(context_cache_state) + withr::defer( + { + rm(list = ls(context_cache_state), envir = context_cache_state) + list2env(old, envir = context_cache_state) + }, + env + ) +} + 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")) + local_context_cache_state() context_cache_state$warned <- NULL context_cache_state$fallback_dir <- NULL @@ -143,6 +157,7 @@ test_that("prune_context_cache() is throttled across builds", { writeLines(strrep("x", 1000), stale) # A prune just happened, and the build count isn't at a multiple of 20 + local_context_cache_state() context_cache_state$n_builds <- 1 context_cache_state$last_prune <- Sys.time() prune_context_cache(dir, max_size = 1) @@ -167,6 +182,7 @@ test_that("prune_context_cache() evicts least-recently-used stores over the size Sys.setFileTime(middle, now - 200) Sys.setFileTime(newest, now - 100) + local_context_cache_state() context_cache_state$n_builds <- 0 context_cache_state$last_prune <- NULL # Cap fits two stores; the oldest is evicted @@ -183,6 +199,7 @@ test_that("prune_context_cache() keeps a single store larger than the cap", { big <- file.path(dir, "big.duckdb") writeLines(strrep("x", 10000), big) + local_context_cache_state() context_cache_state$n_builds <- 0 context_cache_state$last_prune <- NULL context_cache_state$warned_size <- NULL @@ -212,6 +229,7 @@ test_that("prune_context_cache() reaps stale .build-* temp files only", { } Sys.setFileTime(old, Sys.time() - 25 * 60 * 60) + local_context_cache_state() context_cache_state$n_builds <- 0 context_cache_state$last_prune <- NULL prune_context_cache(dir) diff --git a/pkg-r/tests/testthat/test-data-source.R b/pkg-r/tests/testthat/test-data-source.R index e3dbfbf9..5826b908 100644 --- a/pkg-r/tests/testthat/test-data-source.R +++ b/pkg-r/tests/testthat/test-data-source.R @@ -512,18 +512,19 @@ test_that("parquet/csv pins register as zero-copy views over the pins cache", { tables = c(parquet_t = "p-parquet", csv_t = "p-csv") ) + state <- data_source_state(src) for (table in c("parquet_t", "csv_t")) { res <- source_query(src, sprintf("SELECT * FROM %s", table)) expect_equal(nrow(res), 3) # Registered as a view over the cache file, not a copied table catalog <- DBI::dbGetQuery( - src$con, + state$con, "SELECT table_type FROM information_schema.tables WHERE table_name = $1", params = list(table) ) expect_equal(catalog$table_type, "VIEW") # The view reads the pin's versioned file directly - expect_true(file.exists(src$pending$views[[table]]$path)) + expect_true(file.exists(state$pending$views[[table]]$path)) } }) @@ -536,7 +537,7 @@ test_that("rds pins still load eagerly as tables", { res <- source_query(src, "SELECT * FROM orders") expect_equal(nrow(res), 3) catalog <- DBI::dbGetQuery( - src$con, + data_source_state(src)$con, "SELECT table_type FROM information_schema.tables WHERE table_name = 'orders'" ) expect_equal(catalog$table_type, "BASE TABLE") @@ -552,7 +553,7 @@ test_that("a dangling pin view is re-resolved and retried transparently", { src <- data_source(board, tables = c(orders = "orders")) expect_equal(source_query(src, "SELECT * FROM orders")$id, 1L) - view_path <- src$pending$views$orders$path + view_path <- data_source_state(src)$pending$views$orders$path # A rewrite on a non-versioned board deletes the old version's directory suppressMessages( @@ -562,5 +563,5 @@ test_that("a dangling pin view is re-resolved and retried transparently", { # The next query re-resolves the latest version and succeeds expect_equal(source_query(src, "SELECT * FROM orders")$id, 1:2) - expect_true(file.exists(src$pending$views$orders$path)) + expect_true(file.exists(data_source_state(src)$pending$views$orders$path)) }) From dd59db3672e6544b4b00ad5abb0d53107519b5c3 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 31 Aug 2026 18:40:29 -0500 Subject: [PATCH 3/3] Add nanoparquet to Suggests for the parquet pin tests --- pkg-r/DESCRIPTION | 1 + pkg-r/tests/testthat/test-data-source.R | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pkg-r/DESCRIPTION b/pkg-r/DESCRIPTION index ea520960..67a487c6 100644 --- a/pkg-r/DESCRIPTION +++ b/pkg-r/DESCRIPTION @@ -53,6 +53,7 @@ Suggests: dplyr, ggplot2, gt, + nanoparquet, odbc, otel (>= 0.2.0), otelsdk (>= 0.2.0), diff --git a/pkg-r/tests/testthat/test-data-source.R b/pkg-r/tests/testthat/test-data-source.R index 5826b908..ab6dc471 100644 --- a/pkg-r/tests/testthat/test-data-source.R +++ b/pkg-r/tests/testthat/test-data-source.R @@ -501,6 +501,7 @@ test_that("as_data_sources validates its input", { test_that("parquet/csv pins register as zero-copy views over the pins cache", { skip_if_not_installed("pins") + skip_if_not_installed("nanoparquet") board <- pins::board_temp() suppressMessages({ @@ -545,6 +546,7 @@ test_that("rds pins still load eagerly as tables", { test_that("a dangling pin view is re-resolved and retried transparently", { skip_if_not_installed("pins") + skip_if_not_installed("nanoparquet") board <- pins::board_folder(withr::local_tempdir(), versioned = FALSE) suppressMessages(