Versions Directory Sharding
Context
All version files for documents in a given directory share a single flat versions/ subdirectory. A frequently-edited document or a directory with many documents accumulates thousands of files in one directory. findVersions() calls os.ReadDir() on the entire directory and filters by prefix, making it O(all entries) instead of O(document versions). This is the hot path — called on every Get, CurrentVersion, Write, and Versions request.
Problem Dimensions
Two axes of growth compound in the flat layout:
- Many documents in one directory — 1000 docs x 50 versions = 50K entries
- Many versions of one document — 1 doc with 10K versions = 10K entries
Options Considered
A: Per-Document Subdirectory
versions/
doc.md/
v1
v2
v3
other.md/
v1
v2
Symlink: versions/doc.md/v3
Pros: Eliminates cross-product, findVersions scoped to one doc, simple naming.
Cons: Extra directory per document, migration needed.
B: Name-Prefix Sharding
versions/
do/
doc.md.v1
doc.md.v2
ot/
other.md.v1
Pros: Spreads entries across shard buckets.
Cons: Uneven distribution, does NOT help many-versions-of-one-doc, findVersions still scans shard dir.
C: Version-Range Bucketing
versions/
doc.md/
v0001-1000/
v1 ... v1000
v1001-2000/
v1001 ...
Pros: Caps leaf directories at fixed size.
Cons: Overkill for typical use (< 100 versions), findVersions must merge across buckets, cross-bucket complexity in chain verification.
Recommended: Option A — Per-Document Subdirectory with Lazy Migration
New Layout
root/
doc.md → versions/doc.md/v3
versions/
doc.md/
v1
v2
v3
Why This Option
- Solves both scaling axes. Each document's versions are isolated. The
versions/top-level contains only subdirectories (one per document). findVersionsbecomes O(versions) instead of O(all entries in directory). This is the biggest win since it is the hot path.- Minimal code surface. All 12 path construction sites in
store.gofollow the same pattern. Two helper functions replace all of them. - Zero-downtime migration. The lazy migration pattern already exists (
migrateFlatFileat line 1087). Extend it for flat-to-subdir migration. - Symlinks stay simple.
versions/doc.md.v3becomesversions/doc.md/v3— still relative, one level deeper.
Implementation
Step 1: Extract path helpers (pure refactor)
Add two functions in store.go:
// versionFilePath returns the path to a version file on disk.
func versionFilePath(versionsDir, base string, version int) string {
return filepath.Join(versionsDir, base, fmt.Sprintf("v%d", version))
}
// versionSymlinkTarget returns the relative symlink target for a version.
func versionSymlinkTarget(base string, version int) string {
return filepath.Join("versions", base, fmt.Sprintf("v%d", version))
}
Update all 12 call sites to use these helpers. Initially they produce the OLD layout paths — this is a no-behavior-change refactor that can be tested independently.
Step 2: Add layout detection
// isPerDocLayout returns true if the document uses per-document subdirectory layout.
func isPerDocLayout(versionsDir, base string) bool {
info, err := os.Stat(filepath.Join(versionsDir, base))
return err == nil && info.IsDir()
}
Step 3: Update helpers to use new layout
- Writes always create the per-document subdirectory and use new layout
- Reads detect which layout exists and handle both
Step 4: Update findVersions
New layout: os.ReadDir(filepath.Join(versionsDir, base)) — reads only that document's versions, parses v{N} names.
Old layout: falls back to current scan-and-filter logic.
Step 5: Add migrateToPerDocDir
Modeled on existing migrateFlatFile (line 1087). Called from Write before creating new version when old layout is detected:
- Create
versions/{base}/directory - Move all
{base}.v{N}files intoversions/{base}/v{N} - Update symlink atomically
Step 6: Update BuildHashIndex
Currently skips versions/ via filepath.SkipDir. With nested subdirectories inside versions/, this already works — SkipDir skips the entire subtree.
Files to Modify
server/internal/store/store.go— all changes (path helpers, layout detection, migration, findVersions)server/internal/store/store_test.go— tests for both layouts, migration, findVersions performance
Verification
- Run existing tests — they must all pass with no layout change (Step 1 refactor)
- Add tests for new layout: write, read, findVersions, getVersion, verifyChain, archive
- Add test for migration: create documents in old layout, trigger write, verify files moved to new layout
- Add test for mixed layout: old and new layout documents coexist in same directory
- Run
bash pre-commit.sh - Manual smoke test: start server with existing content directory, publish a document, verify versions are in per-doc subdirectory