The contiguous data cache (data/contiguous/data/<hh>/<hh>/<hash>) is
content-addressed and grows without bound unless something reclaims it. Two
independent reclaimers can do that. They share the same disk-usage watermark
environment variables but interpret them differently, so pick one for a given
deployment.
Both watch the cache filesystem with statfs (an O(1) syscall), which is the
authoritative signal for "how full is the disk." Everything else (the index,
TTLs, byte counters) only decides which blobs to remove — never whether the
disk is actually full.
- Enabled when
CONTIGUOUS_DATA_CACHE_CLEANUP_THRESHOLDis set (a base TTL in seconds). - How it reclaims: periodically walks the sharded blob tree and deletes files older than an effective TTL.
- Watermarks tune the TTL (dynamic TTL):
- Below
LOW_WATERMARK_PERCENT→ cleanup is skipped entirely and the cache grows to fill the disk — unless free space is belowMIN_FREE_BYTES, which independently forces aggressive cleanup regardless of usage percent (see below). - At/above
HIGH_WATERMARK_PERCENT(or belowMIN_FREE_BYTES) → cleanup runs aggressively; the effective TTL is progressively tightened from the base threshold (at the high watermark) towardAGGRESSIVE_MIN_AGE_SECONDS(at a full disk, or immediately when the free-space floor is breached). Hysteresis: once aggressive, it keeps draining until usage recovers below the low watermark and free space is back aboveMIN_FREE_BYTES. AGGRESSIVE_MIN_AGE_SECONDSis an absolute floor — data younger than this is never deleted, even at 100% full.
- Below
- Cost: a full walk of
data/contiguousisO(files)random-access I/O. On a large spinning-disk cache (tens of TB, tens of millions of blobs) the walk is seek-bound and cannot keep up. Use the index evictor there instead.
- Enabled when
ENABLE_CONTIGUOUS_DATA_CACHE_INDEX=true. - How it reclaims: maintains an SSD-resident SQLite index
(
contiguous_data_cache) of every cached blob — populated by a write hook on cache writes, so no filesystem walk is needed to decide what to evict. When the disk crosses the high watermark it queries the index for the best eviction candidates and removes them (delete the index row, then unlink the blob) until the disk recovers. - Watermarks are a pressure trigger + drain target (no TTL):
- At/above
HIGH_WATERMARK_PERCENT(or belowMIN_FREE_BYTES) → eviction is triggered. - Eviction runs until usage falls below
LOW_WATERMARK_PERCENTand free space is back aboveMIN_FREE_BYTES(the drain-down-to target), or the index is drained. AGGRESSIVE_MIN_AGE_SECONDSis ignored — the evictor has no age floor.
- At/above
- Eviction order:
ORDER BY tier ASC, last_access ASC— general-tier blobs before preferred-tier, oldest-accessed first within a tier (LRU).
LRU via last_access. Each blob's index row carries cached_at (write time,
informational) and last_access (drives eviction order). When
CONTIGUOUS_DATA_CACHE_INDEX_UPDATE_ON_READ=true (default), a cache hit
refreshes last_access, so hot blobs sink to the bottom of the eviction queue.
With it false, last_access never moves after write and eviction degrades to
FIFO (oldest-written first).
Note:
last_accessreflects only reads that actually reach the gateway. If a caching/reverse proxy sits in front of it (a common deployment), the proxy absorbs a share of repeat reads, so those hits never refreshlast_access. LRU here is a best-effort demand signal, not an exact one.
Tiering (preferred ArNS). Blobs served for a preferred ArNS name are promoted
to tier 1 and evicted only after all tier-0 (general) blobs are gone. A blob
enters tier 1 either at cache-write time (its request matched
PREFERRED_ARNS_NAMES / PREFERRED_ARNS_BASE_NAMES) or on a later read that
matches (promotion; the tier is raised with MAX() and never demoted).
Backfill / reconciler (ContiguousDataCacheReconciler). Enabled with
ENABLE_CONTIGUOUS_DATA_CACHE_INDEX_BACKFILL=true, it does a one-time walk of the
existing on-disk cache to seed index rows for blobs written before the index
existed (all seeded as tier 0). It is resumable — a per-shard checkpoint file
(data/contiguous/.cache-index-backfill-checkpoint) lets a restart skip
already-completed shards. Turn it off once it has completed a full pass; the
write hook keeps the index current thereafter.
Drift is harmless. statfs is authoritative for pressure, so index/disk drift
never over-deletes: a stale index row just unlinks a missing file (ENOENT is
ignored), and a blob missing from the index simply isn't an eviction candidate —
the filesystem-walk worker can still run occasionally as a reconciler if desired.
Byte metrics are logical, not physical. cache_index_evicted_bytes_total and
the size gauges are uncompressed logical sizes. On a compressing filesystem
(e.g. btrfs zstd) the physical space freed is smaller, so trust
node_filesystem_avail_bytes / df — not the byte counters — for actual disk
reclaimed. This is fine: the evictor's stop condition is statfs, not the byte
sum.
| Situation | Use |
|---|---|
| Large / spinning-disk cache (walk can't keep up) | Index evictor (ENABLE_CONTIGUOUS_DATA_CACHE_INDEX=true) |
| Small / SSD cache, simple TTL semantics | Filesystem-walk worker (CONTIGUOUS_DATA_CACHE_CLEANUP_THRESHOLD set) |
Recommended large-cache setup:
ENABLE_CONTIGUOUS_DATA_CACHE_INDEX=trueENABLE_CONTIGUOUS_DATA_CACHE_INDEX_BACKFILL=truefor the first run, then unset once the backfill completes.CONTIGUOUS_DATA_CACHE_HIGH_WATERMARK_PERCENT/_LOW_WATERMARK_PERCENTset to a sensible band (e.g. evict at the high mark, drain to the low mark — keep the band a few points wide so eviction isn't constantly re-triggering).CONTIGUOUS_DATA_CACHE_MIN_FREE_BYTESas an absolute ENOSPC backstop for a volume shared with SQLite DBs/WAL/logs.- Leave
CONTIGUOUS_DATA_CACHE_CLEANUP_THRESHOLDunset so the filesystem-walk worker doesn't also run (or set it if you want it as an occasional reconciler).
See Environment Variables for the full list of related settings.
Everything above describes the contiguous data cache. The chunk data
cache (data/chunks) is a separate volume with a separate lifecycle, tuned by
the CHUNK_DATA_CACHE_* settings rather than the CONTIGUOUS_DATA_CACHE_* ones
above. As of ADR 005 it has the same two reclaimers: the filesystem-walk
worker (FsCleanupWorker, enabled by default via
ENABLE_CHUNK_DATA_CACHE_CLEANUP) and an index evictor
(ChunkDataCacheEvictor, opt-in via ENABLE_CHUNK_DATA_CACHE_INDEX).
Index eviction reclaims a whole data-root directory at a time, which orphans
every by-absolute-offset symlink pointing into it at once.
SymlinkCleanupWorker reaps those on CHUNK_SYMLINK_CLEANUP_INTERVAL
(default 24 h). Measured on a production gateway ~10 h after a 778 GB
data-root-scoped reclaim, 15.6% of sampled absolute-offset symlinks were
already dangling.
This is not a correctness problem — a dangling link reads as ENOENT, which
getByAbsoluteOffset treats as a cache miss, and a later chunk claiming the
same offset replaces the link atomically. But orphans hold inodes and cost a
failed syscall plus a refetch until they are reaped, so shorten
CHUNK_SYMLINK_CLEANUP_INTERVAL when enabling the evictor on a large chunk
cache.
The walk cost described above applies to the chunk cache with interest: it holds far more objects than a contiguous cache of the same size (millions of ~256 KiB chunks vs whole blobs). On a production gateway the chunk cleanup walk was measured spending 93.9 s of every 94.9 s batch cycle traversing rather than deleting; on a second gateway it logged "No more files to delete, restarting from the base path" while 86.7% of cached bytes were already older than its own age floor. Discovery, not eligibility, is the constraint — which is why the "use the index evictor on large caches" advice above now has a chunk-side equivalent.
ENABLE_CHUNK_DATA_CACHE_INDEX=true turns on all three parts at once: the write
hook on FsChunkDataStore.set(), the read hook, and the evictor. The index is
the chunk_data_cache table in chunks.db (not data.db), one row per
data root.
| Variable | Default | Meaning |
|---|---|---|
ENABLE_CHUNK_DATA_CACHE_INDEX |
false |
Master switch: write hook, read hook, and evictor. |
CHUNK_DATA_CACHE_INDEX_EVICTION_INTERVAL_MS |
60000 |
How often the evictor checks disk pressure. |
CHUNK_DATA_CACHE_INDEX_EVICTION_BATCH_SIZE |
1000 |
Max index rows considered per batch within a sweep. |
CHUNK_DATA_CACHE_INDEX_EVICTION_TARGET_BYTES |
1073741824 |
Bytes a batch aims to free (1 GiB). This is what makes eviction size-aware. |
CHUNK_DATA_CACHE_INDEX_UPDATE_ON_READ |
true |
Refresh last_access on cache hits (LRU). false degrades to FIFO by write time — reasonable behind an edge cache. |
ENABLE_CHUNK_DATA_CACHE_INDEX_BACKFILL |
false |
One-time walk to seed rows for a pre-existing on-disk cache. Requires ENABLE_CHUNK_DATA_CACHE_INDEX=true. |
CHUNK_DATA_CACHE_INDEX_BACKFILL_BATCH_SIZE |
2000 |
Rows buffered per backfill insert transaction. |
ENABLE_CHUNK_DATA_CACHE_INDEX_HYBRID_TAIL |
false |
Reserved — currently inert. Nothing reads it; enabling it does nothing. |
CHUNK_DATA_CACHE_INDEX_HYBRID_TAIL_CHUNK_THRESHOLD |
100 |
Reserved — currently inert. |
Pressure thresholds are not separate: the evictor reuses
CHUNK_DATA_CACHE_HIGH_WATERMARK_PERCENT, CHUNK_DATA_CACHE_LOW_WATERMARK_PERCENT
and CHUNK_DATA_CACHE_MIN_FREE_BYTES — trigger at the high mark (or below the
free-space floor), drain to the low mark, statfs authoritative throughout,
exactly as on the contiguous side.
There is one knob that does not exist, deliberately: the eviction age floor. See below.
The unit is the whole by-dataroot/<hh>/<hh>/<dataRoot>/ directory, removed via
FsChunkDataStore.delDataRoot() — not an individual chunk file. Rows carry
size and chunk_count for the entire data root.
Selection is ORDER BY tier ASC, last_access ASC like the contiguous evictor,
but a batch then accumulates candidates until it reaches
CHUNK_DATA_CACHE_INDEX_EVICTION_TARGET_BYTES instead of evicting a fixed
number of coldest rows. That is forced by the size distribution: measured in
production, the median data root holds ~2 chunks (~0.5 MiB) while the top 1% of
data roots hold ~50% of all chunks (p90 ~101 chunks, max ~25,000). A
coldest-first batch of N rows would repeatedly reclaim half-megabyte
directories — paying N index reads and N unlinks to free almost nothing — while
the disk kept filling.
The mirror image of that skew is the known limitation: a large data root is an all-or-nothing eviction unit, so one hot chunk can pin a multi-GiB entry. The "hybrid tail" knobs above are the placeholder for fixing that and are inert today.
Read this before enabling the evictor. The contiguous evictor deliberately
ignores AGGRESSIVE_MIN_AGE_SECONDS (stated earlier in this document): it
has no age floor, and that is safe for content-addressed blobs. The chunk
evictor must honour one, and does.
When the optimistic chunk ingest cache is on, this gateway is the only place a freshly POSTed chunk lives until its data root confirms on chain. Evicting it early breaks upload propagation — and it breaks it silently: the poster already received its 200, the bytes are simply gone when the network comes asking, and the failure resurfaces much later as an unrelated "data unavailable". There is no error to alert on.
The floor is therefore derived in code, not read from the environment, so it cannot drift out of step with the ingest configuration it has to respect:
CHUNK_DATA_CACHE_INDEX_MIN_AGE_SECONDS =
CHUNK_INGEST_CACHE_ENABLED
? max(CHUNK_DATA_CACHE_AGGRESSIVE_MIN_AGE_SECONDS,
CHUNK_INGEST_ALLOWLIST_CONFIRMATION_TIMEOUT_SECONDS)
: CHUNK_DATA_CACHE_AGGRESSIVE_MIN_AGE_SECONDS
At stock defaults with ingest caching on, that is
max(3600, 86400) = 86,400 s (24 h) — the allowlisted-poster confirmation
window, because the floor must cover the worst case, and that timeout is the
longer of the two ingest leashes (CHUNK_INGEST_CONFIRMATION_TIMEOUT_SECONDS
defaults to 21,600 s / 6 h). With ingest caching off (the default) there is
no locally-originated chunk to protect and the floor collapses to
CHUNK_DATA_CACHE_AGGRESSIVE_MIN_AGE_SECONDS (default 3,600 s / 1 h), the same
floor the walk worker honours.
Consequences for operators:
- Do not treat
CHUNK_DATA_CACHE_AGGRESSIVE_MIN_AGE_SECONDSas a pressure knob on a gateway that ingests chunks. Lowering it to make the evictor more aggressive lowers a correctness floor. Size the volume instead. - The floor is enforced twice — in the index query (
last_write <= now - floor) and again per candidate inside the evictor — because a filter that quietly stopped applying would fail invisibly. chunk_cache_index_skipped_floor_totalcounts candidates held back by the floor. A rising value under disk pressure means the floor, not the evictor, is what is limiting reclamation; that is the signal to add capacity, not to lower the floor.- The floor keys off
last_write— MAX(write time) across the data root's chunks, advanced on every chunk write — not first-cached time. A read never advances it. This is what stops a long-lived data root that just received a fresh chunk from being evicted whole. - The same constraint has always applied to the walk worker:
CHUNK_DATA_CACHE_AGGRESSIVE_MIN_AGE_SECONDSmust stay above the ingest confirmation lag, or the walk frees bytes out from under a placement the ingest GC still considers in flight (row present, bytes gone→ later cache misses). Measure that lag from thechunk_placementstable, not fromchunk_ingest_confirmation_latency— the counter is process-lifetime scoped and drops the slow tail.
Enabling the evictor means data-root directories get removed. That races
FsChunkDataStore.set(), which does mkdir then writeFile: if the directory
is evicted between those two calls, the write fails with ENOENT and the chunk is
silently dropped — the caller believes it was cached.
The retry that closes that window (recreate the directory and write once more)
ships in PR #867. An image that predates it must not run the evictor. If
you are unsure, check for the ENOENT retry in src/store/fs-chunk-data-store.ts
before setting ENABLE_CHUNK_DATA_CACHE_INDEX=true. Note the corollary for
manual emergency sweeps: delete files only (find … -type f … -delete),
never rmdir, on an image without the retry.
The same staged rollout proven on the contiguous side:
- Table + write hook.
ENABLE_CHUNK_DATA_CACHE_INDEX=trueon a node whose image includes the ENOENT retry. Let the index accumulate and confirm it tracks the filesystem (chunk_cache_index_entries,chunk_cache_index_bytes). - Backfill.
ENABLE_CHUNK_DATA_CACHE_INDEX_BACKFILL=truefor one pass to adopt the pre-existing on-disk cache, then turn it back off. It is insert-if-absent, so it can never clobber a live write-hook row, and it is resumable via a per-shard checkpoint (data/tmp/chunk-cache-index-backfill-checkpoint). It runs in the background without blocking startup; a full pass over a ~129k-directory / ~917k-file production tree took ~35 s. Operational rule: the backfill flag does nothing on its own —system.tsgates the reconciler on bothENABLE_CHUNK_DATA_CACHE_INDEXandENABLE_CHUNK_DATA_CACHE_INDEX_BACKFILL, so a backfill can never seed rows while the write hook is off (which would leave the index frozen and stale). - Evictor. Enable on one node under real pressure and watch
chunk_cache_index_evicted_bytes_totalandchunk_cache_index_skipped_floor_totalagainstdf. - Compare against an un-migrated node before rolling out fleet-wide.
Every chunk the gateway caches is indexed: chunks cached on the serving path
(the read-through cache) and chunks cached by optimistic ingest (POST /chunk).
Cache hits refresh last_access too, when
CHUNK_DATA_CACHE_INDEX_UPDATE_ON_READ is on.
Images before ar-io-node #944 was fixed indexed only the ingest path; the
read-through cache wrote through a separate store with no index attached. On
those images the evictor works until the backfill's rows are used up and then
frees nothing, while the chunk volume keeps filling. After upgrading, run the
backfill once more (see step 2 above) to adopt everything cached in the
meantime. The backfill resumes from its checkpoint if one exists, so delete a
leftover data/tmp/chunk-cache-index-backfill-checkpoint from an interrupted
earlier pass first, or the shards before it are skipped.
Two signals show when coverage is failing:
- The index-coverage warning. When the disk is over pressure, every indexed row is inside the age floor, and the index's total bytes could not reach the low watermark even once they all age out, the evictor warns (at most once per 15 minutes) that the index "tracks too little to reach the low watermark". It has two possible causes, and the check cannot tell them apart: the index is missing chunk writes, or the volume also holds data the chunk evictor does not own (pressure is measured over the whole filesystem). Only a volume that holds nothing but the chunk cache rules out the second. In the other sweeps where every row is inside the age floor — between warnings, or when the index does track enough bytes — the evictor logs "deferring eviction" at info level instead. That line is benign only when the index tracks enough, so check for the warning before reading it as healthy.
chunk_cache_index_hook_errors_total{hook="write"|"read"}counts index updates that failed. The hooks are fire-and-forget, so a failure never fails the chunk write — this counter is the only place it shows. It stays at 0 when a store was never given the index at all; the coverage warning is what catches that case.
Leave ENABLE_CHUNK_DATA_CACHE_CLEANUP on (or re-enable it periodically). Index
drift is benign in the safe direction — statfs is authoritative for pressure, a
stale row unlinks a missing directory (ENOENT ignored), and an unindexed file is
simply not a candidate — but an unindexed file is also never reclaimed by the
evictor, and index/filesystem drift has bitten this codebase before (ADR 005
records a contiguous index that was empty on one gateway while its evictor freed
0 bytes per sweep). At chunk cardinality the reconciler is not optional. The
evictor distinguishes the two cases in its logs: "index drained but still over
pressure" (drift — run the walk worker) versus "all remaining entries are inside
the age floor" (working as designed — add capacity).
Warning
The walk worker does not honour the derived age floor, and it deletes.
FsCleanupWorker is a destructive TTL reclaimer, not a read-only drift
scanner. Under pressure its floor is CHUNK_DATA_CACHE_AGGRESSIVE_MIN_AGE_SECONDS
alone, which is independent of the ingest confirmation timeouts the index
evictor's floor is derived from. If it is set below
max(CHUNK_INGEST_CONFIRMATION_TIMEOUT_SECONDS, CHUNK_INGEST_ALLOWLIST_CONFIRMATION_TIMEOUT_SECONDS)
while CHUNK_INGEST_CACHE_ENABLED=true, the walk worker can delete an
unconfirmed chunk before its confirmation window closes — the exact failure
the evictor's floor exists to prevent, reached by the other reclaimer.
This is not specific to the index evictor: it applies to any gateway
running the walk worker with ingest caching on, including deployments that
never enable the index. When running both, either raise
CHUNK_DATA_CACHE_AGGRESSIVE_MIN_AGE_SECONDS to meet the derived floor, or
run the walk worker only as an occasional manual sweep with an age floor at
least as large.
See ADR 005 for the measurements and the design rationale, and Environment Variables for the full list of related settings.