R/storage-home.R

Defines functions maybe_storage_location_message home_created_message storage_location_message inform_up_to inform_once_every now_seconds is_memory_dbdir instance_spill_directory resolve_temp_directory temp_directory_override mark_storage_choice_made storage_choice_made mark_home_prompt_declined home_prompt_declined consent_to_create_home check_home_arg describe_storage_home fixed_storage_home resolve_storage_home is_nonempty_string home_subdir session_home session_temp_dir

# Implementation of the storage-location policy documented in `?duckdb_storage`.
# Explained in handbook/usage/storage/README.md, and for the temp/spill resolvers at the bottom, handbook/usage/memory/budget/README.md.
# Extensions and stored secrets live under a single "home" directory that is resolved afresh on every `duckdb()` call;
# the resolvers and the user-facing status function build on the helpers below.

# --- Home root ----------------------------------------------------------------

# The R session's temporary directory (mockable seam).
session_temp_dir <- function() {
  tempdir()
}

# The per-session home, under tempdir().
# Wiped when the R session ends.
session_home <- function() {
  file.path(session_temp_dir(), get_package_name())
}

# The sub-directory of a home root that holds a given kind of state.
# The names ("extensions", "stored_secrets") match DuckDB's own layout, so a home shared with the DuckDB CLI and Python client lines up.
home_subdir <- function(root, kind) {
  file.path(root, kind)
}

# TRUE for a usable scalar path string.
is_nonempty_string <- function(x) {
  is.character(x) && length(x) == 1L && !is.na(x) && nzchar(x)
}

# --- Resolution ---------------------------------------------------------------

# Resolve the home directory this driver uses for extensions and secrets, and the tier that chose it.
# First match wins:
#
#   1. the `home` argument to duckdb()          -> source "argument"
#   2. the `duckdb.home` option                 -> source "option"
#   3. the `DUCKDB_R_HOME` environment variable -> source "env"
#   4. `~/.duckdb`, if it already exists         -> source "shared"
#   5. otherwise: in an interactive session, offer to create `~/.duckdb` once (source "created" if accepted;
#      a "no" uses a per-session tempdir; a cancelled prompt aborts with an error).
#      A non-interactive session uses the per-session tempdir (source "session").
#
# Only branch 5 in an interactive session has side effects (a prompt, and on consent creating the directory, or an error if cancelled).
# The read-only counterpart used by `duckdb_storage_status()` is `describe_storage_home()`.
resolve_storage_home <- function(home = NULL, shared_home = NULL) {
  # `shared_home` is a tri-state explicit override (duckdb() has already checked it is not combined with `home`):
  #   * NULL  -- resolve automatically (the tiers below).
  #   * TRUE  -- opt in to ~/.duckdb, creating it if needed, with no prompt.
  #   * FALSE -- force a per-session tempdir, even if ~/.duckdb already exists
  #              (and ignoring the option/environment variable).
  if (isTRUE(shared_home)) {
    shared <- duckdb_shared_home()
    dir.create(shared, recursive = TRUE, showWarnings = FALSE)
    return(list(root = shared, source = "shared"))
  }
  if (isFALSE(shared_home)) {
    return(list(root = session_home(), source = "session"))
  }

  fixed <- fixed_storage_home(home)
  if (!is.null(fixed)) {
    return(fixed)
  }

  shared <- duckdb_shared_home()
  if (dir.exists(shared)) {
    return(list(root = shared, source = "shared"))
  }

  if (is_interactive() && !home_prompt_declined()) {
    # consent_to_create_home() returns askYesNo()'s TRUE (yes) / FALSE (no) / NA (cancel).
    answer <- consent_to_create_home(shared)
    if (is.na(answer)) {
      # A cancelled prompt is not a decision.
      # Rather than silently pick a location, abort --
      # reusing the storage-location message as the error text so the user sees exactly how to choose.
      # Only an explicit "no" proceeds with a temporary directory.
      abort(
        paste(
          storage_location_message(
            list(root = session_home(), source = "session"),
            interactive = TRUE
          ),
          collapse = "\n"
        )
      )
    }
    if (isTRUE(answer)) {
      dir.create(shared, recursive = TRUE, showWarnings = FALSE)
      if (dir.exists(shared)) {
        inform(home_created_message(shared), class = "duckdb_home_created")
        return(list(root = shared, source = "created"))
      }
      warning(
        "duckdb: failed to create ",
        shared,
        "; falling back to a temporary directory.",
        call. = FALSE
      )
    }

    # An explicit "no", or a creation that failed:
    # remember it so we do not prompt again this session, and fall through to the per-session default.
    mark_home_prompt_declined()
  }

  list(root = session_home(), source = "session")
}

# The tiers of `resolve_storage_home()` that are pure lookups with no side effects:
# the explicit argument, the option, and the environment variable.
# Returns NULL when none is set.
# Shared by the resolver and the read-only `describe_storage_home()`.
fixed_storage_home <- function(home = NULL) {
  if (!is.null(home)) {
    check_home_arg(home)
    return(list(root = path.expand(home), source = "argument"))
  }
  opt <- getOption("duckdb.home")
  if (is_nonempty_string(opt)) {
    return(list(root = path.expand(opt), source = "option"))
  } else if (!is.null(opt)) {
    warning(
      "`duckdb.home` option must be a single non-empty string (a directory path), or NULL.",
      call. = FALSE
    )
    options(duckdb.home = NULL)
  }
  env <- Sys.getenv("DUCKDB_R_HOME", unset = "")
  if (nzchar(env)) {
    return(list(root = path.expand(env), source = "env"))
  }
  NULL
}

# Read-only counterpart to `resolve_storage_home()`, used by `duckdb_storage_status()`: never prompts and never creates a directory.
# A missing `~/.duckdb` is therefore reported as the per-session default.
describe_storage_home <- function() {
  fixed <- fixed_storage_home()
  if (!is.null(fixed)) {
    return(fixed)
  }
  shared <- duckdb_shared_home()
  if (dir.exists(shared)) {
    return(list(root = shared, source = "shared"))
  }
  list(root = session_home(), source = "session")
}

check_home_arg <- function(home) {
  if (!is_nonempty_string(home)) {
    abort(
      "`home` must be a single non-empty string (a directory path), or NULL."
    )
  }
}

# --- Interactive consent ------------------------------------------------------

# Ask, in an interactive session, whether `~/.duckdb` may be created.
# Returns askYesNo()'s TRUE / FALSE / NA (NA when the prompt is cancelled); the caller treats anything but TRUE as a decline.
# Mockable seam: tests bind this directly rather than driving the console.
#
# The default is anchored at `interactive()`, not the `is_interactive()` that gates the prompt in resolve_storage_home().
# The two can disagree:
# setting `rlang_interactive = TRUE` (a common idiom in reverse dependencies' tests)
# makes `is_interactive()` report an interactive session in a process where `readline()` cannot reach a human --
# it returns "" at once, and askYesNo() takes that empty answer as its `default`.
# With a hardcoded TRUE the package would then consent on the user's behalf and create `~/.duckdb` during `R CMD check`,
# exactly what this policy exists to prevent.
# Anchoring the default at `interactive()` makes the unanswerable prompt a "no", which falls through to the per-session tempdir.
# A human at a console still gets the convenient "yes" default.
consent_to_create_home <- function(path) {
  utils::askYesNo(
    paste0("duckdb: create ", path, "?\n"),
    default = interactive()
  )
}

home_prompt_declined <- function() {
  isTRUE(storage_message_state[["home_prompt_declined"]])
}

mark_home_prompt_declined <- function() {
  storage_message_state[["home_prompt_declined"]] <- TRUE
}

# Whether the user has, in this session, chosen a storage location explicitly by passing `home` or `shared_home` to duckdb().
# The storage-location message exists to point at exactly those arguments,
# so once the user has used one of them they have seen the settings
# and later auto-resolved calls no longer announce (see the announce logic in duckdb()).
storage_choice_made <- function() {
  isTRUE(storage_message_state[["choice_made"]])
}

mark_storage_choice_made <- function() {
  storage_message_state[["choice_made"]] <- TRUE
}

# --- Temp / spill directory ---------------------------------------------------

# An explicit temp/spill-directory override via the `duckdb.temp_directory` option or the `DUCKDB_R_TEMP_DIRECTORY` environment variable,
# or NULL if neither is set.
# Unlike the extension/secret home this stays a separate setting: spill files can be large and are unrelated to the extension cache.
temp_directory_override <- function() {
  opt <- getOption("duckdb.temp_directory")
  if (is_nonempty_string(opt)) {
    return(list(directory = path.expand(opt), source = "option"))
  }
  env <- Sys.getenv("DUCKDB_R_TEMP_DIRECTORY", unset = "")
  if (nzchar(env)) {
    return(list(directory = path.expand(env), source = "env"))
  }
  NULL
}

# Resolve the temp/spill directory.
# Temporary storage stays on by default, as in the DuckDB CLI.
# For on-disk databases keep DuckDB's `<db>.tmp` default (`directory` is NULL so the setting is left unset);
# for in-memory databases DuckDB would spill to a `.tmp` directory in the working directory,
# so point each instance at its own directory under the session tempdir instead.
# Overridable; an override is passed through verbatim and never created here,
# like an explicit `temp_directory` in the CLI.
resolve_temp_directory <- function(dbdir) {
  override <- temp_directory_override()
  if (!is.null(override)) {
    return(override)
  }
  if (is_memory_dbdir(dbdir)) {
    return(list(directory = instance_spill_directory(), source = "session"))
  }
  list(directory = NULL, source = "default")
}

# The spill directory for one in-memory database instance:
# `<session_home>/temp/spill-<unique>`.
# The leaf is not created here --
# that is left to the engine,
# which creates it when a query first spills and removes it again when the instance shuts down, exactly as the CLI's `.tmp` behaves.
# Only the parent chain is created, because the engine's directory creation is a single-level `mkdir` that cannot make two missing levels:
# without it, the first spill fails with an IO Error instead (the in-memory corner of the duckdb/duckdb-r#1604 symptom family).
# The leaf is unique per instance because concurrent instances must not share a spill directory:
# the engine's spill file names are deterministic (`duckdb_temp_storage_<size>-<index>.tmp`,
# `duckdb_temp_block-<id>.block`), and a shutting-down instance removes the `duckdb_temp_*` files -- or the whole directory --
# it finds in its own.
instance_spill_directory <- function() {
  root <- file.path(session_home(), "temp")
  dir.create(root, recursive = TRUE, showWarnings = FALSE)
  tempfile("spill-", tmpdir = root)
}

is_memory_dbdir <- function(dbdir) {
  is.null(dbdir) ||
    !nzchar(dbdir) ||
    identical(dbdir, ":memory:") ||
    startsWith(dbdir, ":memory:")
}

# --- Throttled messages -------------------------------------------------------

# Eight hours, in seconds.
STORAGE_MESSAGE_INTERVAL <- 8 * 60 * 60

# Current time in seconds (mockable seam so throttle tests can inject time).
now_seconds <- function() {
  as.numeric(Sys.time())
}

# Session-local throttle state (also holds the "prompt declined" flag).
storage_message_state <- new.env(parent = emptyenv())

# Emit `message` (an rlang-style character vector, names "i"/"!"/"*" for bullets) at most once per `seconds` per session.
# Used for the interactive reminder, where a human can act on it and a gentle time-based cadence fits.
inform_once_every <- function(id, seconds, message) {
  now <- now_seconds()
  last <- storage_message_state[[id]]
  if (is.numeric(last) && (now - last) < seconds) {
    return(invisible(FALSE))
  }
  storage_message_state[[id]] <- now
  inform(message, class = paste0("duckdb_", id))
  invisible(TRUE)
}

# The most times the non-interactive storage-location message is shown per session before it goes quiet for good.
# A bounded count (rather than the time-based throttle used interactively) so that a long-running or automated process --
# which the time throttle would remind forever, once every interval -- eventually stops being told.
STORAGE_MESSAGE_MAX <- 60L

# Emit `message` up to `max` times per session (keyed by `id`), then stay silent.
# The final allowed emission notes that it will not be shown again.
inform_up_to <- function(id, max, message) {
  count <- storage_message_state[[id]]
  count <- if (is.numeric(count)) count else 0L
  if (count >= max) {
    return(invisible(FALSE))
  }
  count <- count + 1L
  storage_message_state[[id]] <- count
  if (count >= max) {
    message <- c(
      message,
      "i" = paste0(
        "This message has been shown ",
        max,
        " times and will not be shown again this session."
      )
    )
  }
  inform(message, class = paste0("duckdb_", id))
  invisible(TRUE)
}

# The informational lines describing a resolved storage location (`resolved` is the list(root, source) from resolve_storage_home()).
# Kept in one place because it is reused both for the non-interactive message and, verbatim,
# as the error text when an interactive prompt is cancelled.
# `interactive = TRUE` selects the variant for the cancelled-prompt error:
# the quickest fix there is to re-run and answer the prompt,
# so it leads with that rather than the `shared_home` arguments a script would use.
storage_location_message <- function(resolved, interactive = FALSE) {
  if (identical(resolved$source, "shared")) {
    c(
      "duckdb is storing downloaded extensions and secrets under ~/.duckdb:",
      "i" = resolved$root,
      "This persists across sessions and is shared with the DuckDB CLI and other clients.",
      "i" = "Run duckdb(shared_home = FALSE) to use a temporary directory instead.",
      "i" = "See ?duckdb_storage for details and alternatives."
    )
  } else {
    keep <- if (interactive) {
      c(
        "i" = "Answer \"yes\" at the prompt to create ~/.duckdb and keep them (suitable for most users).",
        "i" = "Answer \"no\" to use a temporary directory for this session."
      )
    } else {
      c(
        "i" = "Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).",
        "i" = "Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message)."
      )
    }
    c(
      "duckdb keeps downloaded extensions and secrets in a temporary directory:",
      "i" = resolved$root,
      "This is removed when the R session ends.",
      "*" = "Extensions are re-downloaded each session.",
      "*" = "Secrets are lost.",
      keep,
      "i" = "See ?duckdb_storage for details and alternatives."
    )
  }
}

# A short confirmation shown right after the interactive prompt creates ~/.duckdb, so the user sees what accepting actually did.
home_created_message <- function(path) {
  c(
    paste0("duckdb: created ", path, "."),
    "i" = "Extensions and secrets are kept here across sessions, and shared with the DuckDB CLI and other clients.",
    "i" = "See ?duckdb_storage; run duckdb(shared_home = FALSE) for a temporary directory instead."
  )
}

# Called when a new driver is created and the location was chosen by the package itself (a per-session tempdir,
# or an existing ~/.duckdb) rather than requested explicitly.
# Reports where extensions and secrets are going, and how to change or silence it.
# The reminder is throttled differently by mode: interactively,
# at most once every 8 hours (a human can act on it);
# non-interactively, a bounded number of times before going silent for good, so an automated process is not reminded forever.
maybe_storage_location_message <- function(resolved) {
  if (is.null(resolved) || is.null(resolved$root)) {
    return(invisible())
  }
  message <- storage_location_message(resolved)
  if (is_interactive()) {
    inform_once_every("storage_location", STORAGE_MESSAGE_INTERVAL, message)
  } else {
    inform_up_to("storage_location", STORAGE_MESSAGE_MAX, message)
  }
}

Try the duckdb package in your browser

Any scripts or data that you put into this service are public.

duckdb documentation built on Oct. 23, 2026, 5:06 p.m.