Files
oh-my-pi/docs/fs-scan-cache-architecture.md
T
2026-08-03 16:39:23 +02:00

117 lines
6.9 KiB
Markdown

# Filesystem scan cache architecture contract
This document defines the shared Rust filesystem scan cache implemented by `crates/pi-walker` and consumed by native discovery APIs exposed to `packages/coding-agent`.
## Ownership and data model
The cache lives in `crates/pi-walker/src/cache.rs`. It stores owned `CollectedEntry` lists from a directory walk, not final glob, fuzzy, grep, or AST results. `WalkRequest` in `crates/pi-walker/src/lib.rs` applies static filters, ranking, limits, and optional empty-result revalidation around that collection layer.
Current native consumers:
- `crates/pi-natives/src/glob.rs` — opt-in with `GlobOptions.cache`
- `crates/pi-natives/src/fd.rs` (`fuzzyFind`) — opt-in with `FuzzyFindOptions.cache`
- `crates/pi-natives/src/ast.rs` (`astGrep` / `astEdit` discovery) — always cached for directory operands
`crates/pi-natives/src/grep.rs` uses `WalkRequest` for candidate discovery but explicitly sets `.cache(false)`; the current public `GrepOptions` has no cache field.
The public invalidation binding remains `invalidateFsScanCache(path?)` in `packages/natives/native/index.d.ts` / `index.js`. Coding-agent mutation helpers live in `packages/coding-agent/src/tools/fs-cache-invalidation.ts`.
## Cache key partitioning
Each cache key is:
- canonicalized root directory
- the complete effective `WalkOptions` value, with only its `cache` bit cleared
Consequently all traversal-affecting options partition entries: hidden and ignore policy, `.git` and `node_modules` pruning, symlink policy, metadata detail, per-directory order, root emission, min/max depth, contents-first traversal, directory-error policy, and same-filesystem policy. Calls that differ in any of those fields do not share a scan. In particular, `follow_links` **is** part of the current key.
High-level `WalkRequest` filters, ranking, result limits, empty-recheck policy, and size-hint policy are not stored directly in the key. Before collection, size-hint policy and max-file-size filtering can promote effective metadata detail to `Full`, which then partitions the underlying scan.
## Collection behavior
`pi-walker` resolves relative roots against current cwd, requires an existing directory, and canonicalizes it when possible. `WalkOptions` controls traversal; consumers explicitly choose their policies rather than inheriting every walker default.
Collected entries contain normalized forward-slash relative paths and file types. `WalkDetail::Full` additionally requests mtime and regular-file size. Cancellation is delivered through the caller-supplied heartbeat.
Traversal-adjacent parallel work uses a shared Rayon pool:
- `PI_WALK_WORKERS` defaults to `4`
- `0` auto-detects available parallelism
- `1` forces serial work
- helper operations parallelize only at 256 or more items
## Freshness and eviction
Global environment-overridable policy:
- `FS_SCAN_CACHE_TTL_MS` — default `1000`
- `FS_SCAN_EMPTY_RECHECK_MS` — default `200`
- `FS_SCAN_CACHE_MAX_ENTRIES` — default `16`
With caching enabled:
- TTL `0` bypasses cache and returns a fresh scan with `cache_age_ms = 0`.
- A hit younger than TTL clones the stored entries and reports its age.
- An expired entry is removed and replaced by a fresh scan.
- After insertion, entries above the configured maximum are evicted oldest-first by creation time.
With caching disabled, collection scans fresh and neither reads nor populates the shared cache. It does not evict an existing cached entry for the same key.
## Empty-result revalidation
`WalkRequest` owns the recheck policy. `EmptyRecheck::Configured` retries once when:
1. the first collection was a nonzero-age cache hit,
2. the result is empty after the request's high-level filter, and
3. cache age is at least `FS_SCAN_EMPTY_RECHECK_MS` (a configured threshold of `0` disables this mode).
The retry runs uncached and does not replace or evict the existing cached entry. `EmptyRecheck::Never` disables it; `AfterMillis(n)` supplies a request-specific age threshold.
Current effects:
- `glob` integrates its compiled glob and node-module policy into `WalkFilter`, so an empty filtered match set can trigger revalidation.
- AST discovery integrates files-only, optional glob, and node-module filtering, so an empty candidate set can trigger revalidation.
- `fuzzyFind` collects with the default all-entry filter and scores afterward. Revalidation therefore covers an empty underlying walk, not a non-empty walk whose entries all score zero.
- `grep` is uncached, so no cache-age recheck applies.
## Consumer policies
- `glob`: `hidden=false`, `gitignore=true`, `cache=false`; skips `.git`; skips `node_modules` unless the pattern mentions it; never follows symlinks; uses path order and pattern-bounded depth; uses full detail only for mtime sorting.
- `fuzzyFind`: `hidden=false`, `gitignore=true`, `cache=false`; skips `.git` and `node_modules`; follows symlinks always; uses minimal detail and path order.
- `astGrep` / `astEdit` directory discovery: `hidden=true`, `gitignore=true`, cache always enabled; skips `.git`; excludes `node_modules` unless the supplied glob mentions it; never follows symlinks; uses minimal detail and path order.
- `grep`: candidate walks skip `.git`, never follow symlinks, use minimal detail, and are uncached.
The TUI `@`-mention autocomplete opts into cached `fuzzyFind`. Coding-agent's grep tool does not populate this cache.
## Invalidation
`invalidateFsScanCache(path?)`:
- with no path, clears all entries
- with a path, removes every entry whose cached root is a prefix of the target
Relative paths resolve against cwd. Invalidation canonicalizes the target; when it no longer exists, it attempts to canonicalize the parent and reattach the filename. This supports create, delete, and rename invalidation.
Coding-agent helpers:
- `invalidateFsScanAfterWrite(path)`
- `invalidateFsScanAfterDelete(path)`
- `invalidateFsScanAfterRename(oldPath, newPath)` — invalidates both sides when different
Current write, hashline, patch, and replace mutation paths call these helpers after successful changes. Any new filesystem mutation path must do the same.
## Adding a cache consumer
1. Choose stable traversal options and reuse `WalkRequest`; every effective `WalkOptions` difference creates a partition.
2. Put stable candidate filtering in `WalkFilter` when empty-result revalidation should observe it. Post-collection scoring cannot trigger the request's recheck.
3. Use `.cache(false)` for a genuinely fresh request; it bypasses rather than clearing shared state.
4. Select `EmptyRecheck` deliberately. Do not add per-call TTL controls; TTL and default recheck age are global.
5. Invalidate after every successful write, delete, or move; invalidate both sides of a rename.
## Boundaries
- The `DashMap` cache is process-local and is not persisted.
- Entries are full owned scan results, not final tool results.
- Cache hits clone the stored entry vector.
- Sharing occurs only for the same canonical root and complete effective traversal options.