feat(cache): local graph-database cache for localized shards (Grafeo) #142
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Every streamd invocation re-reads and re-parses the entire markdown corpus.
load_markdown_shards(src/cli/commands/mod.rs:20) walks the base folder,fs::read_to_strings every.mdfile, parses it, and localizes it — for all oftodo,edit,daily, andtimesheet. The LSP does the same thing recursively, andcompute_diagnosticsre-walks the whole corpus on every diagnostic request.On a local SSD this is free: measured on the personal corpus (126 files, 235 KB)
streamd todoruns in 12 ms end-to-end including process startup. The real motivation is different: on the work WSL machine the stream directory lives on a OneDrive-synced share where file I/O is very expensive, and that corpus is already ~300 files. There the cost is dominated byread_to_stringper file, not by parsing.Goals, in order:
statonly (size + mtime); read from disk only the files that actually changed.Backend: Grafeo (pure Rust, embeddable, GQL/Cypher). Location:
.streams-cache.grafeoinside the base folder. Graph model: shards + tags + dimension values as first-class nodes.Why Grafeo
flake.nixbuilds four targets — native, musl-static (-C target-feature=+crt-static), mingw-windows cross, and the wasm Zed extension — withstrictDeps = trueand emptybuildInputs: the project today has zero C dependencies, and the.debis built from the musl static binary and executes it inside the Nix sandbox.That constraint eliminates the strongest engine in the field: Kuzu was archived in Oct 2025 (Apple acquisition) and its live successor LadybugDB (
lbug, 0.19.1) is a C++ core viacxx. Of the pure-Rust options, CozoDB is abandoned (no commits since 2024) and Oxigraph is pure-Rust only in-memory (on-disk = RocksDB). SurrealDB embedded is pure Rust withkv-surrealkvbut drags in a dependency tree that would dwarf this 7.9k-line project. Grafeo fits every build constraint.Grafeo is young — created Jan 2026, v0.5.42, pre-1.0 file format, largely one author. That is acceptable only because this database is pure derived state: it is never authoritative, and every failure path rebuilds it from the markdown. The design below makes that guarantee structural.
Known trade-off
.streams-cache.grafeosits inside the OneDrive-synced folder, so OneDrive will upload it on every change (sync churn) and may create conflict copies when two machines write it. The design neutralises the correctness half of this — any unreadable, corrupt, or version-mismatched database is deleted and rebuilt — but the churn remains. Add.streams-cache.grafeo*to.gitignoreand exclude it from OneDrive sync. Switching toProjectDirs::cache_dir()later is a one-line change, since the path is produced by a single function.Design
Key insight: split the graph into two layers
localize_stream_fileis pure — its only inputs are(Shard tree, file name, RepositoryConfiguration, Tz), with no I/O and no clock. That lets the cache be split so the correctness-critical path never depends on which configuration was used::File,:Shard,HAS_CHILD) — config- and timezone-independent. This is what replaces the expensive file reads.:Tag,:DimensionValue,TAGGED,PLACED_IN) — materialized under one canonical config + tz, purely additive, used only for the new graph queries.This matters because the same folder is loaded under different configurations today:
todo/edituseTaskConfiguration,timesheetusesBasicTimesheetConfiguration, the LSP uses the merge of both; andtodo/edithardcodechrono_tz::UTCwhiletimesheetuses the repo timezone. CachingLocalizedShards directly would force the config and tz into the cache key.Instead, the read path reconstructs the raw
Shardtree from the structural layer and calls the existinglocalize_stream_filewith the caller's own config and tz — in memory, no I/O.Consequence: zero behaviour change. Every command produces byte-identical output to today, including the existing
todo/editUTC quirk.Graph model
Nodes:
:Metaschema_version,streamd_version,projection_fingerprint(singleton):Filepath,size,mtime_ns,parse_ok:Shardstart_line,end_line,ordinal,markers(list),tags(list):Tagname(globally deduplicated):DimensionValuedimension,value(globally deduplicated)Edges:
(:File)-[:HAS_ROOT]->(:Shard)(:Shard)-[:HAS_CHILD]->(:Shard)—ordinalon the child preservesVecorder(:Shard)-[:IN_FILE]->(:File)— denormalized; makes subgraph deletion and file lookup one hop(:Shard)-[:TAGGED]->(:Tag)(:Shard)-[:PLACED_IN]->(:DimensionValue)— carries anorderpropertyPLACED_INstores the full effectivelocation(including entries inherited from ancestors), so "all shards in project X" is a single hop. Thefiledimension is excluded — it is alreadyIN_FILE, and duplicating a path string onto every node would bloat the graph for nothing. Scale check: ~300 files × ~10 shards × ~4 dimensions ≈ 12k edges. Trivial.markers/tagsare order-significantVec<String>, so they are stored asValue::List, not as edges. (Tagnodes are the query projection; the list property is the reconstruction source.)Invalidation
Meta.schema_versionmismatch,Metamissing, or any error opening or reading the database ⇒ remove the cache path (file or directory, seestore.rsbelow) and rebuild from scratch.projection_fingerprint(stable hash of the canonical mergedRepositoryConfigurationserialized via the existingserde_jsondependency, plus the tz name) mismatch ⇒ drop and rebuild:Tag/:DimensionValueand their edges, keep the structural layer.WalkDir+entry.metadata()— stat only, no reads. Compare(size, mtime_ns)against the:Filenode. Changed or new ⇒ read + parse + insert. Present in graph but gone from disk ⇒ delete its subgraph viaIN_FILE.Files that fail to localize are recorded with
parse_ok = falseso they are not re-read on every run; todayload_markdown_shardssilently drops them.The scan is recursive (matching the LSP's
list_markdown_files, skipping dot-directories).load_markdown_shards_cachedthen filters to depth 1 to preserve the CLI's currentmax_depth(1)behaviour exactly. This existing CLI/LSP inconsistency is preserved deliberately, not fixed silently.Concurrency
Grafeo's
GrafeoDB::open_read_onlytakes a shared file lock, which implies read-write takes an exclusive one — so a long-lived handle in the LSP would lock out every CLI invocation.Therefore: all database access is short-lived — open, work, close. No handle outlives a single operation. The LSP keeps its existing
DashMapfor the per-keystroke path and touches the database only at startup (the cold-start warm-up, which is exactly the OneDrive pain point) and on watched-file changes.On any lock-acquisition failure, or any Grafeo error at all, fall back to the existing uncached
load_markdown_shards. A user command must never fail because of the cache.Implementation
1. Dependency (
Cargo.toml)Enable exactly one query language. Do not enable
jemallocormimalloc-allocator— both are C libraries and would break the zero-C-dependency build across musl-static and mingw.2. New module
src/cache/mod.rs— public API, re-exportsschema.rs— label/edge/property name constants,SCHEMA_VERSION,projection_fingerprint()store.rs— path resolution (base_folder.join(".streams-cache.grafeo")), open/close,AccessMode/lock handling,rebuild_on_errorwrapper.The
.grafeoextension is deliberate. Per theConfigdocs,StorageFormat::Auto"detects the format from the path:.grafeoextension uses single-file format, directories use the legacy WAL directory" — so.grafeogets the single-file format with no explicitwith_storage_formatcall and no directgrafeo-enginedependency. (StorageFormatis not re-exported from thegrafeocrate; it lives atgrafeo_engine::config::StorageFormat, which would otherwise have to be version-pinned in lockstep.)Defensively,
clear()and the rebuild path must still remove both a file and a directory at the cache path —fs::remove_file, falling back tofs::remove_dir_all, treating "already absent" as success — since the format is extension-driven in a pre-1.0 crate and could change.sync.rs— stat scan, diff against:Filenodes, apply inserts/deletes in one transactionread.rs— reconstructVec<StreamFile>from the structural layerquery.rs— graph-native queries (new capability)Public surface:
load_markdown_shards_cachedis a drop-in replacement forload_markdown_shards: sync the structural layer, reconstructStreamFiles, then map through the existinglocalize_stream_file(&stream_file, config, tz)(src/localize/shard.rs), dropping errors exactly as the current loader does.Use Grafeo's direct-access API (
create_node_with_props,create_edge,get_node,get_neighbors_outgoing) for the hot sync/reconstruct paths — the docs put these at 10–30× the speed of an equivalentMATCH. Reserve GQL forquery.rs.3. Wire in the call sites
src/cli/commands/mod.rs— keepload_markdown_shardsas the uncached fallback; addload_markdown_shards_cachedand switchtodo.rs,edit.rs,daily.rs,timesheet.rsto it. Signatures are unchanged, so the four commands need a one-line edit each.src/cli/commands/lsp.rs— inLspStateconstruction, seedfile_cachefrom the database instead of parsing from disk; ondid_change_watched_files, update the changed file's subgraph. Leaveparse_and_cacheand theDashMaphot path untouched.src/cli/args.rs+ newsrc/cli/commands/cache.rs—streamd cache rebuild|clear|status.STREAMD_NO_CACHE=1escape hatch (clap already has theenvfeature enabled).4. Errors (
src/error.rs)Add
CacheError(String)and aSerdeJsonError(#[from] serde_json::Error)variant —StreamdErrorcurrently has no JSON variant. Cache errors must be internal: they trigger fallback, they do not propagate to the user.5. New graph queries (
src/cache/query.rs)The capability this buys, expressed as GQL over the projection layer:
tags_co_occurring_with(tag)— shards tagged X, then out to their other:Tagnodesshards_in_dimension(dimension, value)— one hop viaPLACED_IN, replacing the recursivefind_shard_by_positionfull-tree scan (src/query/find.rs)dimension_value_counts(dimension)— project/task-state rollupsfiles_touching_date(date)— the indexcompute_diagnosticsrecomputes from scratch on every request todayKeep
src/query/find.rsuntouched and working onVec<LocalizedShard>; these are additive.6. Docs
Per
CLAUDE.md, update both:REQUIREMENTS.md— new section after R25 covering the cache file location, the stat-based invalidation contract, the schema-version/rebuild guarantee, the fallback-on-any-error rule, and thecachesubcommand. Amend R25 to note the LSP's cold-start seeding.README.md— thestreamd cachecommand, the.streams-cache.grafeofile, and the.gitignore/ OneDrive-exclusion recommendation.Verification
Unit/integration tests (
cargo test), using the existingtempfiledev-dependency:Vec<LocalizedShard>==the directly-computed one. Run it underTaskConfiguration,BasicTimesheetConfiguration, and the merge, plus two timezones, to prove config-independence..streams-cache.grafeo, assert the command still succeeds and the database is rebuilt.:Metanode with an oldschema_version, assert full rebuild.load_markdown_shards_cachedfalls back and returns correct results.End-to-end:
Build constraints — the part most likely to break, so check all targets:
The honest success criterion is the warm run on the WSL/OneDrive machine, since that is the only place the current cost is real — locally the baseline is already 12 ms. Capture a before/after number there before considering this done.
Revision: cache file renamed from
.streams-cache.dbto.streams-cache.grafeo— see the refinement comment below for the reasoning.https://claude.ai/code/session_012figbDQRDBcAx11FYarFBu
Refinement: resolve the storage-format open question
The plan body left one item explicitly unverified — "Grafeo's
StorageFormat::Autoinfers from the file extension and may not recognise.db; also confirm whetherConfig::persistenttakes a file or a directory." That is now answered, and the answer changes a decision.Finding
Per the
Configdocs, thestorage_formatfield behaves as:So
Config::persistent(path)accepts either a file or a directory, andAutopicks the format from the path shape:Auto*.grafeo*.db(our case).streams-cache.dbmatches neither documented case. The realistic outcomes are that it is treated as the legacy WAL directory format — i.e. Grafeo creates a directory named.streams-cache.db— or that it errors. Either way the plan'sclear()and corruption-recovery paths, which assume a single file tofs::remove_file, would be wrong.Second finding:
StorageFormatis not re-exported from thegrafeocrate. Its path isgrafeo_engine::config::StorageFormat, andgrafeo's public item list (structs, enums, traits at 0.5.42) does not include it. Callingwith_storage_formatexplicitly therefore means adding a directgrafeo-enginedependency and pinning it in lockstep withgrafeo— for a pre-1.0 crate, an ongoing version-coupling cost.Recommended change
Rename the cache file to
.streams-cache.grafeo.This takes the documented single-file path, needs no explicit
with_storage_formatcall, avoids the second-crate dependency ongrafeo-engine, and keepsclear()a plainfs::remove_file. It still satisfies the original requirement — a dot-prefixed cache file inside the base folder — since only the extension differs from.streams-cache.db.Consequent edits to the plan:
store.rs: path becomesbase_folder.join(".streams-cache.grafeo"); drop thewith_storage_formatnote and the "file or directory" TODO..gitignoreentry becomes.streams-cache.grafeo*, and likewise for the OneDrive exclusion..streams-cache.grafeo.Alternative if the
.dbextension is preferred: addgrafeo-engineas a direct dependency, pass.with_storage_format(StorageFormat::SingleFile)explicitly (confirm the exact variant name at implementation time), and pin both crates to the same version.Defensive requirement either way
Grafeo is pre-1.0 and the format is extension-driven, so a future version could change what a given path produces.
clear()and the rebuild-on-error path must handle both a file and a directory at the cache path — tryfs::remove_file, fall back tofs::remove_dir_all, and treat "already absent" as success. This keeps the plan's core guarantee intact: any unreadable or unexpected on-disk state is discarded and rebuilt from the markdown, never surfaced to the user.Unchanged
Everything else in the plan stands: the two-layer structural/projection split, stat-only invalidation, short-lived open→work→close database access with fallback to
load_markdown_shardson any error, and the build-target verification matrix.https://claude.ai/code/session_012figbDQRDBcAx11FYarFBu
Decision: accepted. The cache file is
.streams-cache.grafeo.The issue description has been updated in place, so it is now the single authoritative plan — the implementer does not need to reconcile it against the comment above. Changes applied:
base_folder.join(".streams-cache.grafeo")throughout (Context,store.rs, docs section, corruption-recovery test).store.rsno longer callswith_storage_format, and nografeo-enginedependency is added —StorageFormat::Automaps.grafeoto the single-file format on its own.clear()and the rebuild path must remove both a file and a directory at the cache path, as a hedge against the extension-driven format changing in this pre-1.0 crate..gitignore/ OneDrive exclusion pattern is now.streams-cache.grafeo*.The
plannedlabel remains attached; no other part of the design changed.https://claude.ai/code/session_012figbDQRDBcAx11FYarFBu
Implemented — PR #143
Cost
Token usage: not reported. The session's context was compacted partway through, so the running counter reset and I cannot read a trustworthy cumulative figure. Rather than estimate one, the measurable proxies are above.
Findings
The plan's dependency line was wrong.
grafeo = { default-features = false, features = ["gql"] }enables neither the LPG property-graph store nor persistence —gqlmaps only tografeo-engine/gql, andGrafeoDB::openis gated behindwal. Nothing would have compiled against it. Correct set is["edge", "storage"]. Worth noting the failure mode: a trivial spike (fn main(){}) compiled fine against the wrong features, so this only surfaced by reading the feature graph in the vendored source.Grafeo held up. 46 crates, no
*-sys/cc/bindgen, ~10 s clean build. The zero-C-dependency premise survived all four targets, including the musl static-pie binary and the mingw cross. The.grafeoextension does produce a single file, as the refinement predicted.The exclusive lock is stricter than assumed. A held read-write handle blocks
open_read_onlytoo, not just other writers. The short-lived open→work→close discipline and the fallback path were therefore load-bearing, not defensive extras.Two real bugs, both caught by tests rather than review:
open_or_rebuildtreated any open failure as corruption and deleted the file, so a second process would wipe a database the first was actively using. Now distinguished and propagated untouched; pinned bytest_a_locked_cache_is_not_deleted.load_markdown_shardswas order-nondeterministic. It returnedWalkDirorder, and consumers use a stablesort_by_key(|s| s.moment)— so two files sharing a timestamp could order arbitrarily between runs. Both paths now sort on the timestamp-prefixed filename.Also: GQL literals needed escaping, since the tag pattern admits apostrophes (
@don't).A test premise was wrong, not the code. No preconfigured marker populates the
projectdimension — it is declared but awaits repo-level config. My first rollup test assumed@Project-Xdid something; retargeted attaskandfile_type, which are actually populated.The honest result
On this machine the cache is a net loss: 14 ms warm vs 13 ms uncached, 25 ms cold. Parsing 235 KB is already trivial, so graph open + stat + reconstruct costs marginally more than it saves. Correctness is solid —
todoandtimesheetare byte-identical with and without it — but the performance premise remains unproven until it runs against the OneDrive share. If it does not pay off there,STREAMD_NO_CACHE=1andstreamd cache clearmake it inert.Deviations
:Daynodes added.files_touching_dateneeds dates, which are localization output rather than structural facts; modelling days as nodes keeps canonical moments out of the config-independent layer.Needs your action
Your stream folder is a git repo with no ignore entry for the cache. Add
.streams-cache.grafeo*to its.gitignore, and exclude the file from OneDrive sync — it is a binary rewritten on every note change, so syncing it causes upload churn and cross-machine conflict copies. I left no cache file behind there.https://claude.ai/code/session_012figbDQRDBcAx11FYarFBu