Expected `identical(hash1, hash2)` to be FALSE.
Differences:
`actual`:   TRUE 
`expected`: FALSE

# Helper: Count cache entry files, excluding graph.rds
count_cache_entries <- function(cache_dir, backend_pattern = "\\.(rds|qs2)$") {
files <- list.files(cache_dir, pattern = backend_pattern)
test_that("cacheFile works with basic caching behavior", {
test_that("cacheFile caches results and avoids re-running", {
on.exit(unlink(cache_dir, recursive = TRUE, force = TRUE))
# Strategy: Return a unique ID (Time) that changes every time the body runs.
cached_fun <- cacheFile(cache_dir) %@% f
# Sleep to ensure time would definitely change if it re-ran
# B. Run ID must match exactly (Proves it loaded from disk)
# If this fails, the function re-executed.
test_that("cacheFile tracks multiple dir arguments and vector paths", {
if (exists("cacheTree_reset", mode = "function"))
unlink(cache_dir, recursive = TRUE, force = TRUE)
dir.create(cache_dir, showWarnings = FALSE, recursive = TRUE)
unlink(dir1, recursive = TRUE, force = TRUE)
unlink(dir2, recursive = TRUE, force = TRUE)
dir.create(dir1, showWarnings = FALSE, recursive = TRUE)
dir.create(dir2, showWarnings = FALSE, recursive = TRUE)
sum(vapply(c(path1, path2), function(p) length(list.files(p)), integer(1)))
cached_fun <- cacheFile(cache_dir = cache_dir) %@% fun
# add another file to dir2; hash should change
test_that("cacheFile works with 'qs2' backend", {
if (exists("cacheTree_reset", mode = "function")) cacheTree_reset()
f <- cacheFile(cache_dir, backend = "qs2") %@% function(x) {
# Verify the file extension is actually .qs2
test_that("cacheFile handles arguments that fail to evaluate", {
f <- cacheFile(cache_dir) %@% function(x) x
# The decorator attempts to evaluate args to check for paths.
# This ensures the decorator's tryCatch handles the error gracefully
# and allows the error to bubble up from the actual function call (or the arg eval)
# rather than crashing inside the internal path checking logic.
test_that("cacheFile treats implicit defaults equal to explicit values", {
f <- cacheFile(cache_dir, backend="rds") %@% function(a, b = 10) {
# 2. Call using explicit value (should match default hash)
# 3. Check file count (cache entries only, excluding graph.rds)
test_that("Smart hashing detects file modification without new files (mtime check)", {
on.exit(unlink(data_dir, recursive = TRUE), add = TRUE)
cached_f <- cacheFile(cache_dir,  backend="rds") %@% f
test_that("xxhash64 backend works and produces valid filenames", {
# Force backend to rds for consistent counting
cached_f <- cacheFile(cache_dir, algo = "xxhash64", backend = "rds") %@% function(x) x
test_that("Empty directory handling works with mtime hashing", {
on.exit(unlink(empty_dir, recursive = TRUE), add = TRUE)
cached_f <- cacheFile(cache_dir, backend="rds") %@% f
# Test Feature 6: Environment Variable Tracking
test_that("env_vars argument invalidates cache when env vars change", {
cached_f <- cacheFile(cache_dir, env_vars = "TEST_CACHE_VAR", backend = "rds") %@% f
cached_rds <- cacheFile(cache_dir, backend = "rds") %@% function(x) x
if (requireNamespace("qs2", quietly = TRUE)) {
cached_qs2 <- cacheFile(cache_dir, backend = "qs2") %@% function(x) x
# FEATURE 1: Dual Hashing (Values AND Expressions)
test_that("Cache invalidates when VALUES change (even if expression is same)", {
f <- cacheFile(cache_dir) %@% function(a) {
# Run 2: Expression passed is still 'x', but value is 20.
### OBSOLETE - we don't want to cache variable names
# test_that("Cache invalidates when EXPRESSIONS change (even if value is same)", {
#   cache_dir <- file.path(tempdir(), "test_expr_change")
#   on.exit(unlink(cache_dir, recursive = TRUE))
#   f <- cacheFile(cache_dir) %@% function(a) {
#   # Value is identical (100), but the source code (expression) changed from 'val_A' to 'val_B'.
#   # Ideally, this should be a MISS to ensure provenance safety.
# test_that("Cache invalidates when LITERALS change to VARIABLES (even if value is same)", {
#   cache_dir <- file.path(tempdir(), "test_literal_vs_var")
#   # Expression changed from `50` to `x`. Should Miss.
test_that("Dots (...) are order-independent (sorted by name)", {
f <- cacheFile(cache_dir) %@% function(...) {
list(args = list(...), id = as.numeric(Sys.time()))
# The hasher sorts named arguments, so this should match (HIT).
test_that("Dots (...) detect new arguments", {
test_that("Cache persists across separate R sessions (Disk Persistence)", {
skip_if(!file.exists("../../DESCRIPTION"), "Not in package source tree")
on.exit(unlink(cache_dir, recursive = TRUE), add = TRUE)
stop("Could not find package root containing DESCRIPTION")
if (!requireNamespace("cacheR", quietly = TRUE)) {
if (!requireNamespace("pkgload", quietly = TRUE)) stop("Need pkgload")
# Returns the TIME it ran (to verify caching)
worker <- cacheFile(dir) %@% function(x) {
cached_pid = Sys.getpid() # This gets baked into the cache
# Return BOTH the actual process ID and the cached result
real_pid = Sys.getpid(),  # <--- Captured OUTSIDE the cache
result = worker(10)       # <--- Captured INSIDE the cache
session_a <- callr::r(run_job, args = list(dir = cache_dir, pkg_root = pkg_root))
session_b <- callr::r(run_job, args = list(dir = cache_dir, pkg_root = pkg_root))
# 1. Verify the PROCESSES were different (using the PID captured outside)
# 2. Verify the CACHE HIT (using the timestamp captured inside)
# Session B should return the timestamp from Session A
# 3. Verify the PID WAS CACHED (Optional Proof)
# The 'cached_pid' returned by Session B will be Session A's PID,
# because it loaded the result from disk!
test_that("changes in options used by function invalidate cache (no runs counter)", {
dir.create(cache_dir, recursive = TRUE, showWarnings = FALSE)
on.exit(options(cacheR.test.multiplier = old_opt), add = TRUE)
f <- cacheFile(cache_dir = cache_dir) %@% function(x) {
# Desired behaviour: invalidation because option changed
# Tests for .atomic_save error handling (Issue #3)
test_that(".atomic_save warns when saving to a non-writable path", {
# Directly test .atomic_save with a path that cannot be written
bad_path <- file.path(tempdir(), "nonexistent_subdir_xxyz", "test.rds")
# saveRDS may emit its own low-level warning before our handler runs,
# so capture all warnings and check that ours is among them
warns <- capture_warnings(.atomic_save(list(value = 42), bad_path, "rds"))
expect_true(any(grepl("cacheR: failed to save cache file", warns)))
test_that(".atomic_save warns on read-only cache directory but function still returns result", {
f <- cacheFile(cache_dir = cache_dir) %@% function(x) x * 2
# First call should work normally (cache dir is writable)
on.exit(Sys.chmod(cache_dir, mode = "0755"), add = TRUE)
on.exit(unlink(cache_dir, recursive = TRUE, force = TRUE), add = TRUE)
# New args to force a cache miss; should warn but still return result
regexp = "cacheR: failed to save cache file"
test_that(".atomic_save cleans up temp files on failure", {
# Create a dir, then make it read-only to trigger failure
unlink(test_dir, recursive = TRUE, force = TRUE)
dir.create(test_dir, showWarnings = FALSE, recursive = TRUE)
# Write a file first, then make dir read-only
warns <- capture_warnings(.atomic_save(list(value = "test"), target_path, "rds"))
# Ensure no temp files or target file exist
# Tests for Issue #4: deparse width-dependence
test_that("options(width) does not invalidate cache (Issue #4)", {
on.exit(options(width = old_width), add = TRUE)
list(val = x * 2, run_id = as.numeric(Sys.time()))
# run_id must match — proves cache was hit despite width change
test_that("body change still invalidates cache after AST hashing (Issue #4)", {
cached_f1 <- cacheFile(cache_dir, backend = "rds") %@% f1
cached_f2 <- cacheFile(cache_dir, backend = "rds") %@% f2
# Two different cache files should exist (different body hashes)
# Note: graph.rds also matches, so we expect >= 3 (2 cache + 1 graph)
files <- list.files(cache_dir, pattern = "\\.rds$")
# Exclude graph.rds to count only cache files
# Tests for Issue #6: Default args evaluated in wrong environment
test_that("default referencing another arg evaluates correctly (Issue #6)", {
list(val = a + b, run_id = as.numeric(Sys.time()))
# Call with explicit b = 10 (same value as default would give)
# Should be a cache hit since b resolves to same value
test_that("default with stop() falls back gracefully (Issue #6)", {
# Supplying y explicitly should work fine
test_that("default referencing function closure evaluates correctly (Issue #6)", {
# Tests for Issue #14: Probabilistic hash improvements
test_that("mid-file change detected in medium file under full hash threshold (Issue #14)", {
# Create a 2MB file (under the 5MB full hash threshold)
test_that("seed produces diverse offsets for different paths (Issue #14)", {
# Two different paths with the same file size should get different sampling offsets
seed1 <- strtoi(substring(digest::digest(paste0("/path/a.bin", size), algo = "crc32"), 1, 7), base = 16L)
seed2 <- strtoi(substring(digest::digest(paste0("/path/b.bin", size), algo = "crc32"), 1, 7), base = 16L)
test_that("full hash threshold boundary works correctly (Issue #14)", {
# File at exactly 5MB — should get full hash
# Modify a byte in the middle — full hash should detect it
# Tests for Issue #1: Functions returning NULL
test_that("function returning NULL is cached and not re-executed (Issue #1)", {
# Should still be 1 — cache hit, no re-execution
test_that("function returning NULL with different args creates separate entries (Issue #1)", {
# Two different args should create two cache entries
# Tests for Issue #2: invisible() attribute preserved
test_that("invisible() is preserved on cache miss (Issue #2)", {
test_that("invisible() is preserved on cache hit (Issue #2)", {
test_that("visible return stays visible after cache reload (Issue #2)", {
# Tests for Issue #7: Recursive self-calling cached functions
test_that("recursive cached function produces correct results (Issue #7)", {
fib <- cacheFile(cache_dir) %@% function(n) {
test_that("recursive cached function uses cache for repeated subcalls (Issue #7)", {
# Second call with same args should return correct result (cache hit)
test_that("call stack is properly maintained during recursion (Issue #7)", {
# Call stack should be empty after completion (all on.exit handlers fired)
# Tests for Issue #12: .load parameter collision
test_that(".load parameter collision with wrapped function is documented (Issue #12)", {
# A function that has a parameter named .load
f <- function(x, .load = "default_val") {
# Calling with .load = "custom" collides with the wrapper's .load parameter.
# The wrapper interprets .load as its own caching control flag.
# This is a known limitation — document that it errors.
test_that("wrapped function's .load default is ignored in cache key (Issue #12)", {
# When .load is not explicitly passed, it goes to the wrapper (TRUE),
# not the inner function. The inner function's .load default is not used.
f <- function(x, .load = "inner_default") {
# This call works because .load is not explicitly passed,
# so the wrapper gets .load=TRUE (its own default).
# But the inner function's .load parameter is missing.
# The behavior depends on whether match.call picks it up.
r <- tryCatch(cached_f(1), error = function(e) e)
# Document actual behavior: either works with inner default or errors
expect_true(TRUE)  # collision causes error — documented
# Tests for Issue #15: Positional vs named argument equivalence
test_that("positional and named args hit same cache entry (Issue #15)", {
test_that("mixed positional and named args hit same cache (Issue #15)", {
list(val = a + b + c, run_id = as.numeric(Sys.time()))
test_that("reversed named args hit same cache as positional (Issue #15)", {
list(val = a - b, run_id = as.numeric(Sys.time()))
# Named args in reverse order — match.call should canonicalize
describe("File path detection in R objects", {
it("detects file path in S4 object slot", {
setClass("TestFileRef", representation(path = "character", label = "character"))
obj <- new("TestFileRef", path = tmp, label = "test")
# extract_paths_recursively should find the path
# replace_paths_with_hashes should produce a hash that changes when file changes
hash1 <- cacheR:::.replace_paths_with_hashes(obj, algo = "xxhash64")
hash2 <- cacheR:::.replace_paths_with_hashes(obj, algo = "xxhash64")
it("detects file paths in nested S4 object", {
setClass("TestNestedRef", representation(files = "list", name = "character"))
on.exit(unlink(c(tmp1, tmp2)), add = TRUE)
obj <- new("TestNestedRef", files = list(tmp1, tmp2), name = "dataset")
it("detects file path in custom attribute", {
hash1 <- cacheR:::.replace_paths_with_hashes(x, algo = "xxhash64")
hash2 <- cacheR:::.replace_paths_with_hashes(x, algo = "xxhash64")
it("detects file paths in data frame columns", {
df <- data.frame(id = 1:2, file = c(tmp, "nonexistent_file.txt"),
it("end-to-end: cache invalidates when file in S4 slot changes", {
cache_dir <- file.path(tempdir(), paste0("s4_e2e_", as.integer(Sys.time())))
list(data = readLines(obj@filepath), run_id = as.numeric(Sys.time()))
cached_f <- cacheFile(cache_dir, backend = "rds") %@% f
input <- new("TestInput", filepath = tmp)
# Same object, same file content — cache hit
# Change the file content — should invalidate cache
it("end-to-end: cache invalidates when file in attribute changes", {
cache_dir <- file.path(tempdir(), paste0("attr_e2e_", as.integer(Sys.time())))
list(content = readLines(src), run_id = as.numeric(Sys.time()))
it("S4 object with no file paths produces no false positives", {
setClass("TestNumeric", representation(value = "numeric", label = "character"))
obj <- new("TestNumeric", value = 42, label = "not_a_file_path")
# Issue #8: Functions that error leave dangling graph nodes
describe("Graph node cleanup on function error (#8)", {
it("removes graph node from disk when function errors", {
cache_dir <- file.path(tempdir(), paste0("err_graph_", as.integer(Sys.time())))
f <- function(x) stop("intentional error")
# graph.rds should have no nodes for this function
it("removes graph node from memory when function errors", {
cache_dir <- file.path(tempdir(), paste0("err_mem_", as.integer(Sys.time())))
it("preserves graph node when function succeeds", {
cache_dir <- file.path(tempdir(), paste0("ok_graph_", as.integer(Sys.time())))
# Issue #9: Lock files and temp files accumulate indefinitely
describe("cachePrune cleans lock and temp files (#9)", {
cache_dir <- file.path(tempdir(), paste0("prune_lock_", as.integer(Sys.time())))
lock_before <- list.files(cache_dir, pattern = "\\.lock$")
tmp_before <- list.files(cache_dir, pattern = "\\.tmp\\.")
lock_after <- list.files(cache_dir, pattern = "\\.lock$")
tmp_after <- list.files(cache_dir, pattern = "\\.tmp\\.")
it("does not prune recent cache files", {
cache_dir <- file.path(tempdir(), paste0("prune_keep_", as.integer(Sys.time())))
saveRDS(list(value = 1), file.path(cache_dir, "func.abc.rds"))
# Issue #10: .file_state_cache management functions
describe("File state cache management (#10)", {
it("cache_file_state_info reports cached entries", {
expect_true(normalizePath(tmp, winslash = "/", mustWork = FALSE) %in% info1$paths)
it("cache_file_state_clear empties the cache", {
it("re-hashing works after clearing file state cache", {
# Tier 1 Cleanup: Bounded file state cache and cacheTree_save/load
it("evicts entries when limit is exceeded", {
assign(".file_state_cache_limit", 5L, envir = asNamespace("cacheR"))
assign(".file_state_cache_limit", old_limit, envir = asNamespace("cacheR"))
tmp_dir <- file.path(tempdir(), paste0("lru_test_", as.integer(Sys.time())))
on.exit(unlink(tmp_dir, recursive = TRUE), add = TRUE)
# Create and hash 10 files (exceeds limit of 5)
f <- file.path(tmp_dir, paste0("file_", i, ".txt"))
# After eviction + new entry, should be well under 10
it("round-trips graph nodes through save/load", {
cache_dir <- file.path(tempdir(), paste0("save_load_", as.integer(Sys.time())))
# Tier 2: cache_stats, verbose miss, .cacheR.yml, better errors
test_that("cache_stats returns correct aggregate statistics", {
# Populate with cached functions (use rds backend for predictability)
add_one <- cacheFile(cache_dir = tmp, backend = "rds") %@% function(x) x + 1
add_two <- cacheFile(cache_dir = tmp, backend = "rds") %@% function(x) x + 2
# Files are small, so total_size_mb may round to 0 — check raw file sizes instead
files <- list.files(tmp, pattern = "\\.(rds|qs2)$", full.names = TRUE)
test_that("cache_stats errors on non-existent directory", {
test_that("verbose mode reports 'first execution' on first call", {
f <- cacheFile(cache_dir = tmp) %@% function(x) x * 2
msgs <- capture.output(f(42), type = "message")
test_that("verbose mode reports which component changed on miss", {
msgs <- capture.output(f(2), type = "message")
test_that("verbose mode is silent when option is not set", {
msgs <- capture.output(f(1), type = "message")
test_that(".load_cacheR_config loads settings from YAML file", {
test_that(".load_cacheR_config does not override existing options", {
# Should keep "rds" since it was already set
test_that("env_vars from config option is used by cacheFile", {
f <- cacheFile(cache_dir = tmp, backend = "rds") %@% function(x) x + 1
# Check the cache entry metadata includes sys_envs (proving env_vars was picked up)
files <- list.files(tmp, pattern = "\\.rds$", full.names = TRUE)
# sys_envs should contain the HOME value
test_that("warning on non-writable cache directory", {
cacheFile(cache_dir = tmp) %@% function(x) x,
test_that("warning on corrupt cache file during load", {
# First call to establish function name pattern
# Next call with same args should warn about unreadable file and re-execute
# Tier 3: Conditional caching, versioning, dependency declaration
# --- Feature 9: .force and .skip_save ---
test_that(".force = TRUE re-executes even when cache exists", {
# Use env-based counter to avoid changing closure hash
f <- cacheFile(cache_dir = tmp, backend = "rds") %@% function(x) {
# Result should be saved (force re-writes cache)
cache_files <- list.files(tmp, pattern = "\\.rds$", full.names = TRUE)
test_that(".skip_save = TRUE does not write cache file on miss", {
# No cache file should exist (only graph.rds)
test_that(".force + .skip_save combined: re-executes and doesn't save", {
cache_files_before <- list.files(tmp, pattern = "\\.rds$", full.names = TRUE)
# Force + skip_save: re-executes but doesn't overwrite cache
r2 <- f(2, .force = TRUE, .skip_save = TRUE)
cache_files_after <- list.files(tmp, pattern = "\\.rds$", full.names = TRUE)
test_that("version parameter: same version hits cache", {
f <- cacheFile(cache_dir = tmp, backend = "rds", version = "1.0") %@% function(x) {
test_that("version parameter: different version causes cache miss", {
f_v1 <- cacheFile(cache_dir = tmp, backend = "rds", version = "1.0") %@% function(x) {
f_v2 <- cacheFile(cache_dir = tmp, backend = "rds", version = "2.0") %@% function(x) {
test_that("version = NULL (default) works normally", {
expect_equal(state$n, 1L)  # cache hit with NULL version
# --- Feature 11: Dependency declaration ---
test_that("depends_on_files: file change causes cache miss", {
f <- cacheFile(cache_dir = tmp, backend = "rds", depends_on_files = config) %@% function(x) {
# Clear file state cache so the new content is detected
expect_equal(state$n, 2L)  # cache miss due to file change
test_that("depends_on_vars: different values cause cache miss", {
f_v1 <- cacheFile(cache_dir = tmp, backend = "rds",
depends_on_vars = list(schema = "v3")) %@% function(x) {
f_v2 <- cacheFile(cache_dir = tmp, backend = "rds",
depends_on_vars = list(schema = "v4")) %@% function(x) {
expect_equal(state$n, 2L)  # cache miss — different vars
test_that("depends_on_files and depends_on_vars default to NULL without affecting behavior", {
expect_equal(state$n, 1L)  # cache hit, no explicit deps
# Parallel: Sentinel-based duplicate work prevention
test_that("sentinel file is created during execution and cleaned up after", {
computing_files <- list.files(tmp, pattern = "\\.computing$")
if (length(computing_files) > 0) sentinel_seen <<- TRUE
# After execution, sentinel should be gone
test_that("sentinel wait loads result from parallel worker", {
# First, create a cached result so we know the expected outfile name
f <- cacheFile(cache_dir = tmp, backend = "rds") %@% function(x) x * 10
# Delete the cache file and create a sentinel (simulating another worker computing)
# Launch a background process that creates the cache file after 3 seconds
bg <- callr::r_bg(function(outfile, sentinel) {
saveRDS(list(value = 50, meta = list(fname = "f")), outfile)
}, args = list(outfile = outfile, sentinel = sentinel))
# Main process should wait and pick up the result
withr::with_options(list(cacheR.wait_timeout = 30, cacheR.poll_interval = 1), {
test_that("stale sentinel (> 1 hour old) is ignored", {
# Find the outfile, delete it, create a stale sentinel
# Function should execute immediately (not wait on stale sentinel)
test_that("cachePrune cleans up .computing sentinel files", {
expect_equal(length(list.files(tmp, pattern = "\\.computing$")), 2)
expect_equal(length(list.files(tmp, pattern = "\\.computing$")), 0)