Fast-hash: a local hashcache to skip re-reading unchanged files¶
Status: approved design — pending implementation plan Date: 2026-06-29 Scope:
archivehashing performance + pointer-file default. One spec, two phases.
Problem¶
archive re-reads and re-hashes every local binary on every run (ArchiveCommandHandler
Stage 2 streams each file through IEncryptionService.ComputeHashAsync). For a large
(~1 TB), mostly-stable repository — the motivating case is a Synology NAS running Arius in a
Docker container — that means re-reading ~1 TB each run even when almost nothing changed.
Whether the bottleneck is disk/network I/O or CPU, the only way to make a stable archive fast
is to stop re-reading files whose content has not changed.
Detecting "unchanged" without reading all the bytes is inherently a heuristic. Arius values correctness over throughput (AGENTS.md "Scale and durability"), so the heuristic is opt-in and every uncertainty falls back to a full re-hash.
Goals¶
- Skip re-reading + re-hashing binaries whose content is unchanged, on an explicit opt-in.
- Keep the default behaviour byte-for-byte identical to today (full re-hash).
- Make the change-detection decision independent of timestamp precision/timezone fragility across Windows / macOS / Linux (Synology Docker) — the cross-platform worry that motivated this work.
- Make
--no-pointersthe effective default (pointers add filesystem noise), without losing data or breaking hosts. - Require no pointer-format change and no repository migration; existing v5 and v7 repos work unchanged.
Non-goals¶
- Sub-file content-defined chunking (a one-byte edit still re-uploads the whole file).
- Concurrent multi-machine archiving of the same files (see Invariants).
- Tamper resistance against a privileged adversary (the heuristic targets accidental change, not deliberate evasion).
Decisions (resolved during brainstorming)¶
| # | Decision | Choice |
|---|---|---|
| 1 | Trust model | Opt-in --fast-hash. Default stays full-hash. |
| 2 | Verdict signals | size + platform change-signals (ctime/inode/dev) + sparse fingerprint (deterministic spot-hashes). mtime is stored but NOT in the verdict. |
| 3 | ctime+inode fast-path | Built in v1 (target is local POSIX/Windows native filesystems; cache is always local). |
| 4 | Cold/empty cache | Full-hash on miss (safest). No snapshot/pointer seeding (documented seam). |
| 5 | Pointer format | No v7.1, no migration. Pointers stay bare-hash v7. |
| 6 | Pointer default | --write-pointers (default off) replaces --no-pointers on archive; --remove-local implies it. |
| 7 | Source of truth | Remote (chunk-index + snapshots). Hashcache is a disposable local accelerator, validated against the live file, never against a pointer. |
Why no v7.1 / no migration¶
The snapshot already stores path → content-hash, created, modified per file
(FileTreeModels.FileEntry); the chunk index stores the size. Enriching the pointer would
(a) only help --write-pointers users (pointers are off by default), (b) create a second
source of fast-hash metadata that can drift from the cache, and (c) force a messy v5→v7.1
migration that cannot populate the sparse fingerprint for pointer-only (--remove-local)
files, since their binary isn't local. The hashcache owns the fingerprint; the pointer stays a
bare content-hash.
Architecture¶
Component overview¶
archive (Stage 2: Hash workers ×4)
│
▼
HashCacheService ──► HashCacheLocalStore (SQLite, single-writer)
│ ~/.arius/{account}-{container}/hash/cache.sqlite
│
├─ TryGetVerifiedHash(path, liveSignals) → ContentHash | miss
└─ Upsert(path, signals, sparse_fp, hash)
RelativeFileSystem
└─ TryGetChangeSignals(path) → (ctime, inode, dev)? // platform-specific
HashCacheService— the facade Stage 2 consumes. Owns the verdict ladder and the sparse fingerprint computation. Modeled onChunkIndexService(facade over a private SQLite store).HashCacheLocalStore— sole owner of the SQLite schema/connections/transactions; writes serialize on a gate + busy-retry, exactly likeChunkIndexLocalStore.RelativeFileSystem.TryGetChangeSignals— new IO-boundary method returning the platform's cheap change signals, ornullwhen unavailable (then the floor applies). It returnsnullon network filesystems by design (GetDriveType()==DRIVE_REMOTE/ UNC on Windows; CIFS/NFSf_typeon Linux), because a redirector'sChangeTime/file-id can be cached or unstable — see "Behaviour on network filesystems" below.
The hashcache (SQLite)¶
Location: ~/.arius/{account}-{container}/hash/cache.sqlite (new
RepositoryLocalStatePaths.GetHashCacheRoot). WAL, synchronous = normal,
user_version = 1. Local, disposable, rebuilt by full-hashing on loss.
Schema (one row per binary-present path):
| column | type | role |
|---|---|---|
path |
TEXT PK | repository-relative canonical path |
size |
INTEGER | bytes |
mtime |
INTEGER | UTC ticks — diagnostics only, not in the verdict |
ctime |
INTEGER NULL | inode change time / Windows ChangeTime (UTC ticks) when available |
inode |
TEXT NULL | inode (POSIX, 64-bit) / Windows FileId (128-bit on ReFS) — stored as text for width-uniformity, like dev |
dev |
TEXT NULL | device id / Windows VolumeSerialNumber |
signal_set |
INTEGER | provenance tag: which signals this row was captured with (platform/capability) |
sparse_fp |
BLOB | combined fingerprint (32 bytes) |
fp_algo |
INTEGER | fingerprint-scheme version |
content_hash |
TEXT | cached ContentHash (hex) |
last_verified |
INTEGER | UTC ticks — seam for future age/audit policy |
Platform signal mapping¶
TryGetChangeSignals returns the same abstract triple on every platform; only the source API
differs. To be recorded in the ADR:
| Abstract signal | POSIX (Linux/macOS) | Windows |
|---|---|---|
ctime |
statx/stat st_ctim (inode change time) |
FILE_BASIC_INFO.ChangeTime |
inode |
st_ino (64-bit) |
FILE_ID_INFO.FileId (FILE_ID_128; 64-bit NTFS, 128-bit ReFS) |
dev |
st_dev |
FILE_ID_INFO.VolumeSerialNumber |
ChangeTime is not settable via the documented SetFileTime (only Creation/Access/Write
are) — moving it requires the native NtSetInformationFile, the same deliberate/abnormal-API
class as backdating POSIX ctime. The 128-bit FileId makes ID reuse even less likely than a
POSIX 64-bit inode, strengthening the inode-change guard.
Provenance guard: two different mismatches, two different fallbacks, so we never compare
incomparable values:
- fp_algo mismatch (fingerprint scheme bumped) → the stored sparse_fp is incomparable →
full-hash.
- signal_set / dev mismatch (cache moved to another platform/volume, or signals
unavailable) → only the ctime/inode fast-path is disabled; the platform-independent
sparse_fp is still valid → fall to the sparse-fingerprint floor.
Neither can produce a wrong "unchanged". This is what makes the cross-platform-sequential story (below) safe.
The --fast-hash verdict ladder (per file, in the Stage-2 hash worker)¶
stat → liveSize
sig = fs.TryGetChangeSignals(path) // (ctime, inode, dev)? — may be null
row = cache.lookup(path)
if row is null → FULL-HASH, upsert // cache miss
elif row.fp_algo != CURRENT_FP_ALGO → FULL-HASH, upsert // fp incomparable
elif liveSize != row.size → FULL-HASH, upsert // size changed
elif sig present
and sig.signal_set == row.signal_set
and (sig.dev, sig.inode, sig.ctime)
== (row.dev, row.inode, row.ctime) → UNCHANGED: reuse row.content_hash // skip ALL reads
(refresh last_verified)
else:
liveFp = computeSparseFp(file, liveSize) // ≤ ~1 MiB of reads (or whole small file)
if liveFp == row.sparse_fp → UNCHANGED: reuse row.content_hash
(refresh ctime/inode/dev/mtime/last_verified)
else → FULL-HASH, upsert // content changed
- The
ctimefast lane requiresdev+inode+ctimeto all match (same file, untouched): the kernel bumpsctimeon any inode change and it can't be set to an arbitrary value through normal syscalls, so this is content-unchanged with very high confidence — no reads at all. A changedinode/dev(file replaced, or volume changed) simply fails this condition and falls to the floor; it never trusts a cross-inodectime. - The sparse-fingerprint branch is the portable floor: it runs when change-signals are
absent (exFAT/FAT/network FS), when the file was replaced (
inode/devchanged), or whenctimemoved but size is unchanged (distinguishes a benignchmod/metadata touch from a real content change). - A reused
content_hashflows into the existing dedup/router stage unchanged, so a verified-unchanged file is never re-read and never re-uploaded.
Safety argument. A misprediction toward "changed" only causes a wasted full re-hash (safe). The only unsafe misprediction is "unchanged when actually changed"; every signal added to the "declare unchanged" gate can only add reasons to say "changed", so it never increases the unsafe direction.
The two unsafe failure modes are per-path, not conjunctive — a file reaches "unchanged" via
exactly one path and is exposed to exactly one mode:
- ctime fast-lane failure (a): ctime unchanged despite a content change. On a native
local filesystem the kernel bumps ctime/ChangeTime on any content write, so this
cannot happen by accident — defeating it is deliberate timestomping: POSIX needs a clock
backdate (privileged) or raw block-device write; Windows needs the native
NtSetInformationFile (the documented SetFileTime can't touch ChangeTime). Either way it
is abnormal, never a side effect of a normal write. The fast-lane is therefore watertight
against accidental corruption.
- sparse-fingerprint floor failure (b): a content change that keeps identical size and
misses every sampled region. This is the real residual, but narrow — it applies only to files
that were touched (so they fell off the fast-lane) yet kept identical size, and it shrinks
as K grows with size. The in-place fixed-size-record edit is its archetype; the file-type /
audit seam is its mitigation.
Both modes are accepted only under the opt-in --fast-hash and are documented in the ADR.
Behaviour on network filesystems (SMB/CIFS/NFS)¶
Not a supported target (an archive runs on the machine holding the files — see Invariants),
but it must degrade safely rather than be undefined. Over SMB a redirector exposes
ChangeTime/file-id values that may be cached or unstable across reconnects, so trusting them
risks a false "unchanged". Therefore TryGetChangeSignals returns null on network mounts and
the verdict falls to the sparse-fingerprint floor: size + a few ranged reads per file.
The result is "works, just slower" — still far cheaper than full-hash (≤ ~16 MiB read per file
vs the whole file), with the zero-read ctime lane disabled and no correctness asterisk.
Sparse fingerprint¶
- Size-scaled sampling (not a fixed block count): block
B = 256 KiB; number of regionsK = clamp(ceil(size / stride), 4, 64)withstride ≈ 1 GiB. So bytes read grow with size but stay bounded: 1 MiB for small files … 16 MiB for very large files (a 3 TB file → capped at 64 blocks = 16 MiB, regardless of size). FinalB/stride/Kmaxare tuned by benchmark. - Regions are at deterministic offsets from size: head at
0, tail atsize - B, and the rest evenly spaced —offset_i = floor(i · (size - B) / (K - 1)),i = 0..K-1. Determinism is what lets the same regions be re-sampled each run (size is the precondition for comparing). - Files
≤ K·Bare read whole (sampling degenerates to the full small file — cheap per file, a genuine content check, and one sequential read with no seeks). sparse_fp = SHA256(size ‖ region-bytes)— a single combined 32-byte fingerprint (not per-region; any mismatch ⇒ full-hash, so which region changed is irrelevant).- Versioned by
fp_algo; changingB/stride/Kmax/offset scheme bumps it and invalidates old rows safely. - Reads the raw file directly via the seekable
FileStream(fs.OpenRead) — not through the compress/encrypt wrappers. On local disk the head/interior/tail seeks are cheap random reads; on a network FS each region is one ranged read (a round-trip), and small files avoid per-region round-trips by being read whole. A file that shrank betweenstatand read yields a short tail read → treated as changed → full-hash (safe). - Coverage limit (documented): for genuinely enormous in-place-mutating files (VM images,
large databases) even 16 MiB across 64 points is sparse, so a constant-size mid-file edit can
be missed. Such files are poor
--fast-hashcandidates; the mitigation is the file-type /last_verifiedaudit-escalation seam — full-hash by type or age — not denser sampling.
Cache population on normal (non---fast-hash) runs¶
A normal run already streams every byte through ComputeHashAsync. The sparse fingerprint is
computed from that same stream at zero extra I/O (tee the sampled regions as they pass),
the change-signals are captured, and the row is upserted. So a normal archive warms the cache
and a subsequent --fast-hash run is immediately effective — no wasted first pass.
Logging (trust + debuggability for an opt-in heuristic)¶
Per file (debug): the decision and its reason, e.g.
[fast-hash] {path} → reused (ctime match) ·
→ reused (size+fp match) ·
→ full-hash (size 100→120) ·
→ full-hash (fp differs) ·
→ full-hash (ctime moved, fp differs) ·
→ full-hash (cache miss) ·
→ full-hash (inode changed).
Per run (info): a summary — reused N (ctime M, fp K), rehashed R; reasons: ….
Pointer default flip (--write-pointers)¶
- CLI: remove
--no-pointersfromarchive; add--write-pointers(default off).restorekeeps its own--no-pointers(default writes pointers) — unchanged. - Behaviour: with pointers off, suppress pointer creation/refresh for files that have a local binary. Pointer-only (thin) entries are always preserved, and legacy v5 pointer-only files are still upgraded in place — they are the sole local record for a removed binary.
--remove-localimplies--write-pointers(removing the binary while writing no pointer would orphan the path). This replaces today's mutual-exclusion error with an implication.- Data safety:
--no-pointerskeeps the binary, so nothing is lost locally; the remote (chunk index + snapshots) stays authoritative.
Host impact (verified in code)¶
ls/ ListQuery derivesLocalPointerfrom on-disk presence (LocalDirectoryReader). With pointers off, binary-present files simply lack theLocalPointerflag and show asLocalBinary | Repository— correct, not broken. Pointer-only files still showLocalPointer.- Explorer colours a pointer indicator from that flag — it reads "no local pointer", truthful.
- Api passes
NoPointersthroughJobRunner; flipping the Core default propagates (the host's own job default is a separate config choice). - Web archive pane is redesigned (below).
Web archive pane redesign¶
Today: two mutually-exclusive checkboxes (Remove local binaries / Skip pointer files) plus
a "these are mutually exclusive" note (a footgun). Replace with:
- A segmented/radio "On disk after archive:"
- Keep files only — default (no pointers)
- Keep files + pointers —
--write-pointers - Replace files with pointers —
--remove-local(implies pointers) - A separate toggle
⚡ Fast hash — skip re-reading unchanged files(--fast-hash) with a one-line tooltip about the trust trade.
The radio makes invalid combinations unrepresentable, so the mutual-exclusion note is removed.
Phasing¶
Phase 1 — fast-hash core
- RepositoryLocalStatePaths.GetHashCacheRoot; HashCacheLocalStore (schema, single-writer);
HashCacheService (verdict ladder + sparse fingerprint).
- RelativeFileSystem.TryGetChangeSignals (Linux/macOS stat/statx, Windows
GetFileInformationByHandleEx; null fallback, incl. network-FS detection).
- --fast-hash option wired through ArchiveCommand → ArchiveVerb; Stage 2 integration;
inline cache population on normal runs; logging.
Phase 2 — pointer default + hosts
- --write-pointers (default off) replacing --no-pointers on archive; --remove-local
implication; preserve/upgrade pointer-only entries.
- Web archive pane redesign; Api/host default alignment; restore-side left unchanged.
Documented seams (not built)
- Cold-start cache seeding from the snapshot (mtime-trust) — declined for now.
- last_verified-driven age/audit re-verification and file-type escalation policy (also the
mitigation for enormous in-place-mutating files the sparse fingerprint under-covers).
- mtime-only ultra-fast tier (schema already stores mtime).
Invariants¶
- An archive is local and never concurrently multi-platform. Sequential cross-platform use (e.g. archive on Windows → restore on Linux → archive on Windows) is supported because each machine's hashcache is independent and a stored signal is only ever compared to a live signal read from the same filesystem that produced it. To be recorded in AGENTS.md.
- The hashcache is a disposable local accelerator. Losing/corrupting it costs time (a full-hash run), never data. The remote is the source of truth.
- The cache is validated against the live file, never against a pointer. Pointers are not in the fast-hash loop.
- The default path is unchanged. Without
--fast-hash, hashing behaviour is byte-for-byte as today. mtimeis never load-bearing in v1 — the verdict uses content-derived signals (size,ctime/inode, sparse fingerprint), keeping decisions independent of timestamp precision/timezone across platforms.
Testing¶
- Unit — each ladder branch: cache miss, provenance/
signal_set/devmismatch,fp_algobump,inode/devchange, size change,ctimematch (skip reads),ctimemoved + fp match, fp differ. Sparse-fp determinism, deterministic offsets, size-scaledK(small whole-read; large file capped atKmax), and short-tail-read-on-shrink → changed. Provenance guard. - Cross-platform — the size+sparse-fp verdict is OS-independent by construction (assert
identical decisions Linux/Windows);
TryGetChangeSignalsreturns sane values on native FS,nullon network/unsupported FS (→ floor, asserted on a CIFS/NFS-like mount). - Integration / E2E — warm cache skips re-read (assert via hash/read events); cache deletion
forces full re-hash;
--fast-hashoff = today's behaviour;--write-pointersdefault off;--remove-localstill writes pointers; a file edited preserving size but changing a sampled region is detected.
Docs to update¶
- New ADR (ADR-0021): opt-in change-detection heuristic, the sparse-fingerprint gap, the
disposable-local-cache + not-concurrently-multi-platform invariant, and the platform signal
mapping (POSIX
st_ctim/st_ino/st_dev↔ WindowsChangeTime/128-bitFileId/VolumeSerialNumber, incl. theSetFileTime-can't-set-ChangeTimecaveat). docs/design/— newcore/shared/hashcache.md; updatearchive-command.md(Stage 2) andencryption.md(hashing seam).docs/glossary.md—hashcache,sparse fingerprint,fast-hash.AGENTS.md— the local/not-concurrently-multi-platform archive invariant.README.md—--fast-hashand the pointer default, in human terms.