d230c5e86b
Three-part design doc for an IEventStore backed by a directory tree instead of SQLite, targeted at the cli/ module. - overview: goals, directory layout, feature-parity matrix, public API - pipelines: insert (T1-T8 with crash-safety), query planner, delete, transactions, concurrency - nips: replaceable/addressable slot enforcement via hardlinks + atomic rename, NIP-09 tombstones (hardlink to the kind-5), NIP-40 expirations, NIP-50 FTS via inverted-index hardlinks, NIP-62 vanish cascade, tag indexing, seed file, scrub/compact, test strategy, 10-step rollout
327 lines
11 KiB
Markdown
327 lines
11 KiB
Markdown
# File-backed event store for `amy` — Part 2: Pipelines
|
||
|
||
**Status:** plan · **Date:** 2026-04-24 · **Part 2 of 3**
|
||
(see `2026-04-24-file-event-store-overview.md`,
|
||
`2026-04-24-file-event-store-nips.md`)
|
||
|
||
Covers insert, query, delete, transaction, and concurrency. NIP-
|
||
specific enforcement (09/40/50/62) and replaceable/addressable slot
|
||
mechanics are in Part 3.
|
||
|
||
---
|
||
|
||
## Insert pipeline
|
||
|
||
Mirrors `SQLiteEventStore.insertEvent()`
|
||
(`quartz/.../sqlite/SQLiteEventStore.kt:189-196`) and
|
||
`innerInsertEvent()` (lines 178-187). Module ordering is identical
|
||
— only the backing representation changes.
|
||
|
||
### Pre-transaction guards (no files touched)
|
||
|
||
```
|
||
1. if (event.kind.isEphemeral()) return // EphemeralModule
|
||
2. if (event.isExpired()) throw ExpiredEventException // ExpirationModule
|
||
```
|
||
|
||
Both match SQLite behaviour exactly — rejected before any I/O.
|
||
|
||
### Transaction body
|
||
|
||
Acquire `FileChannel.lock(.lock)` for the whole body. Release only
|
||
on COMMIT or ROLLBACK.
|
||
|
||
```
|
||
T1. Stage event file
|
||
stagingDir = .staging/<random>/
|
||
write stagingDir/event.json (event bytes, fsync if AMY_FSYNC=1)
|
||
|
||
T2. Pre-insert veto checks (read-only)
|
||
• TombstoneIdCheck: tombstones/id/<event.id> exists? → REJECT
|
||
• TombstoneAddrCheck: (addressable only) tombstones/addr/<k>/<pk>/<hash(d)>
|
||
exists AND Tdel >= event.createdAt? → REJECT
|
||
• VanishCheck: tombstones/vanish/<owner_hash> exists AND
|
||
Tvanish >= event.createdAt? → REJECT
|
||
• ReplaceableGuard: (kinds 0,3,10000-19999) replaceable/<k>/<pk>.json
|
||
exists AND its createdAt >= event.createdAt? → REJECT
|
||
• AddressableGuard: (kinds 30000-39999) addressable/<k>/<pk>/<hash(d)>.json
|
||
exists AND its createdAt >= event.createdAt? → REJECT
|
||
Any REJECT → discard staging dir, release lock, return.
|
||
|
||
T3. Canonical write
|
||
canonical = events/<id[0:2]>/<id[2:4]>/<id>.json
|
||
Files.move(stagingDir/event.json, canonical, ATOMIC_MOVE)
|
||
Files.setLastModifiedTime(canonical, event.createdAt seconds)
|
||
Files.setPosixFilePermissions(canonical, r--r--r--)
|
||
|
||
T4. Derived-index hardlinks (all ln(canonical → X))
|
||
• idx/kind/<k>/<ts>-<id>
|
||
• idx/author/<pk>/<ts>-<id>
|
||
• idx/owner/<owner_hash>/<ts>-<id> # recipient for GiftWrap
|
||
• for each indexed tag (name, value):
|
||
idx/tag/<name>/<murmur_hex>/<ts>-<id>
|
||
• if event.expiration() > 0:
|
||
idx/expires_at/<exp_ts>-<id>
|
||
• if event implements SearchableEvent:
|
||
for each token in tokenize(event.indexableContent()):
|
||
idx/fts/<token>/<id>
|
||
|
||
T5. Replaceable / Addressable slot (if applicable)
|
||
slotTmp = <slot>.tmp.<random>
|
||
ln canonical slotTmp
|
||
Files.move(slotTmp, slot, ATOMIC_MOVE, REPLACE_EXISTING)
|
||
# The rename atomically deposes the old winner and installs new.
|
||
if oldSlot had different event id:
|
||
unlink events/<old_id>.json # drop canonical
|
||
walk idx/... and unlink entries for old id # reuses same logic as delete()
|
||
|
||
T6. Deletion-event side effects (if event.kind == 5)
|
||
Apply NIP-09 actions — see Part 3. Executes *inside* the lock.
|
||
|
||
T7. Vanish-event side effects (if event.kind == 62 && shouldVanishFrom(relay))
|
||
Apply NIP-62 cascade — see Part 3.
|
||
|
||
T8. Commit
|
||
Release .lock. Remove emptied staging dir.
|
||
```
|
||
|
||
### Crash safety at every step
|
||
|
||
| Crash between | Post-crash state | Scrub action |
|
||
|---|---|---|
|
||
| T1 and T3 | Only staging dir exists | Delete `.staging/` on next open |
|
||
| T3 and T4 | Canonical written, no indexes | Rebuild indexes by walking `events/` |
|
||
| T4 (partial) | Some indexes written | Rebuild indexes (idempotent) |
|
||
| T5 (partial) | Slot tmp hardlink exists | Remove `<slot>.tmp.*` on next open |
|
||
| T5 (after rename) | Old winner still has canonical | Slot takes precedence; old canonical GC'd by scrub |
|
||
| T6/T7 (partial) | Some targets unlinked, some not | Re-apply kind-5/62 during scrub |
|
||
|
||
The kernel guarantees `rename(2)` atomicity; `link(2)` either
|
||
succeeds fully or fails. All failure modes are forward-recoverable
|
||
by scrub — never corrupting.
|
||
|
||
### Ordering vs SQLite
|
||
|
||
SQLite runs triggers *before* our code inserts. We run checks
|
||
before writing the canonical (T2), then indexes (T4), then slot
|
||
enforcement (T5), then kind-5 / kind-62 side effects (T6/T7). Same
|
||
overall guarantees, different sequencing because we have no trigger
|
||
mechanism.
|
||
|
||
---
|
||
|
||
## Transaction API
|
||
|
||
```kotlin
|
||
override fun transaction(body: ITransaction.() -> Unit) {
|
||
withLock {
|
||
val txn = FsTransaction(this)
|
||
try {
|
||
txn.body() // accumulates staged events
|
||
txn.commit() // all T3..T7 for every staged event, then one unlock
|
||
} catch (e: Throwable) {
|
||
txn.rollback() // delete staging dirs, do nothing else
|
||
throw e
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Semantics:
|
||
|
||
- Single `flock` for the whole transaction.
|
||
- Staging dirs collected; canonical moves happen at commit time.
|
||
- If any event in the batch is rejected by T2, the whole txn aborts.
|
||
- Matches SQLite's all-or-nothing behaviour
|
||
(`SQLiteConnectionExt.kt:71-81`).
|
||
|
||
No readers are blocked: they don't hold the lock and see either
|
||
pre-commit or post-commit state (atomic rename).
|
||
|
||
---
|
||
|
||
## Query pipeline
|
||
|
||
Matches the planner structure of `QueryBuilder`
|
||
(`quartz/.../sqlite/QueryBuilder.kt:110-172`) but emits directory
|
||
walks instead of SQL.
|
||
|
||
### Filter normalisation
|
||
|
||
Same as SQLite: `filter.toFilterWithDTags()` splits the d-tag out of
|
||
the generic tag map, flags `isSimpleQuery` / `isSimpleSearch`.
|
||
|
||
### Plan selection
|
||
|
||
For each filter, choose one driver index based on cardinality and
|
||
availability:
|
||
|
||
| Filter contents | Driver index | Rationale |
|
||
|---|---|---|
|
||
| Only `ids` | direct `events/<aa>/<bb>/<id>.json` opens | O(1) per id |
|
||
| `kinds` present, no tags | `idx/kind/<k>/` | Smallest per-kind fanout |
|
||
| `authors` present, no kinds, no tags | `idx/author/<pk>/` | Author-scoped |
|
||
| `kinds` + `authors` + no tags | Pick the smaller of `idx/kind/` vs `idx/author/` by listing size | Parity with SQLite's `query_by_kind_pubkey_created` choice |
|
||
| Any `#tag` filter | `idx/tag/<n>/<h>/` — intersect multiple if AND | Matches SQLite's per-tag INNER JOINs |
|
||
| Only `search` | `idx/fts/<token>/` — intersect tokens | Parity with FTS MATCH |
|
||
| Only `since`/`until` / `limit` | `idx/kind/*/` or full `events/` walk ordered by mtime | See below |
|
||
| `d_tag` present (addressable) | `addressable/<k>/<pk>/<hash(d)>.json` direct open | Slot lookup — O(1) |
|
||
|
||
Chosen driver produces a **sorted stream of `(createdAt, id)` pairs**
|
||
in DESC order (lexicographic sort over zero-padded filenames reversed).
|
||
Filenames are `<ts>-<id>`, so `Files.list(dir).sorted(reverseOrder())`
|
||
is the plan.
|
||
|
||
### Secondary filters (post-driver)
|
||
|
||
Anything not covered by the driver is applied in code, event by
|
||
event, after opening the JSON:
|
||
|
||
- `since <= createdAt <= until` (already a prefix filter on the driver)
|
||
- `authors` / `kinds` / `ids` if not the driver
|
||
- d-tag match (addressable)
|
||
- additional tag filters
|
||
- search tokens (if driver wasn't FTS)
|
||
- tombstone exclusions (id + address)
|
||
- vanish exclusions (by owner_hash)
|
||
- expiration exclusions (`createdAt < now < expiration`)
|
||
|
||
### Tag AND (NIP-91)
|
||
|
||
For `filter.tagsAll = {#e = [a, b], #p = [c]}`:
|
||
|
||
```
|
||
streamA = idx/tag/e/<hash(a)>/ (sorted by <ts>-<id> DESC)
|
||
streamB = idx/tag/e/<hash(b)>/
|
||
streamC = idx/tag/p/<hash(c)>/
|
||
result = k-way intersection by <id> suffix, preserving DESC order
|
||
```
|
||
|
||
Implemented as a sorted-list intersection (Java `Iterator<String>`
|
||
merge). Equivalent to SQLite's INNER JOIN per tag
|
||
(`QueryBuilder.kt:434-449`). Bounded memory.
|
||
|
||
### Multi-filter UNION
|
||
|
||
`query(filters: List<Filter>)` runs each filter's plan, then merges
|
||
the resulting streams in DESC order with dedup by id. Per-filter
|
||
`limit` applied before merge, as SQLite does
|
||
(`QueryBuilder.kt:577-583`).
|
||
|
||
### Streaming mode
|
||
|
||
`query(filter, onEach: (T) -> Unit)` calls `onEach` as each event is
|
||
read and parsed. No intermediate list. Matches SQLite's cursor
|
||
callback.
|
||
|
||
### `count()`
|
||
|
||
Same plan, but open nothing — just count filenames that survive the
|
||
post-driver code filters. For filters that require JSON inspection
|
||
(tag exact match on non-indexed tags, search tokens), we still open
|
||
files; cost scales with matched set size, not store size.
|
||
|
||
### `planQuery()` output
|
||
|
||
Plain text, like `EXPLAIN QUERY PLAN`:
|
||
|
||
```
|
||
FILTER 0
|
||
DRIVER: idx/kind/1/ (12,400 entries)
|
||
POST: authors=[<pk>], since >= 1713960000, limit 100
|
||
FILTER 1
|
||
DRIVER: idx/tag/e/ab12…/ ∩ idx/tag/p/cd34…/ (est 37 hits)
|
||
POST: kinds=[30023], limit 10
|
||
MERGE: 2 filters, DESC by created_at, overall limit 110
|
||
```
|
||
|
||
---
|
||
|
||
## Delete pipeline
|
||
|
||
### `delete(filter)` / `delete(filters)`
|
||
|
||
Runs the query pipeline to enumerate matching events, then for each:
|
||
|
||
```
|
||
1. Read canonical, parse tags (needed to know all hardlink paths).
|
||
2. Unlink every derived hardlink:
|
||
idx/kind/<k>/<ts>-<id>
|
||
idx/author/<pk>/<ts>-<id>
|
||
idx/owner/<owner_hash>/<ts>-<id>
|
||
idx/tag/<n>/<h>/<ts>-<id> (for each indexed tag)
|
||
idx/expires_at/<exp>-<id> (if exp present)
|
||
idx/fts/<token>/<id> (for each FTS token)
|
||
3. If event is the winner of a replaceable/addressable slot:
|
||
unlink the slot path.
|
||
4. Unlink canonical events/<aa>/<bb>/<id>.json.
|
||
5. kernel GC's the inode (no other refs).
|
||
```
|
||
|
||
All inside `flock`. Matches SQLite's
|
||
`DELETE FROM event_headers WHERE row_id IN (...)` with FK cascade.
|
||
|
||
### `delete(id: HexKey): Int`
|
||
|
||
Direct shortcut. Opens `events/<aa>/<bb>/<id>.json`, runs the same
|
||
steps 1-5. Returns `1` on success, `0` if the file didn't exist —
|
||
matching the SQLite return value from `changes()`
|
||
(`SQLiteEventStore.kt:261-264`).
|
||
|
||
### `deleteExpiredEvents()`
|
||
|
||
```
|
||
now = clock.now().epochSecond
|
||
for entry in idx/expires_at/ where <exp> < now:
|
||
delete(entry.id)
|
||
```
|
||
|
||
Optional optimisation: because filenames are `<exp>-<id>`, listing is
|
||
already sorted, so we can stop at the first entry with `<exp> >= now`.
|
||
|
||
---
|
||
|
||
## Concurrency model
|
||
|
||
### Single writer, many readers
|
||
|
||
- Writers hold an exclusive `flock(.lock)` via `FileChannel.tryLock()`.
|
||
- Readers take a shared lock only while listing top-level directory
|
||
contents that may be mid-rename; most reads hold no lock (kernel-
|
||
atomic `open` / `readdir` is enough).
|
||
- Two concurrent `amy` invocations serialise cleanly on the flock;
|
||
the second one blocks on `FileChannel.lock()` (blocking) or fails
|
||
fast with `tryLock()` (configurable).
|
||
|
||
### Reader–writer interaction
|
||
|
||
Because every writer mutation is a rename-over-tmp, readers see
|
||
either the pre-mutation file or the post-mutation file, never a
|
||
torn view. Dangling reads (writer unlinks a file between
|
||
`readdir` and `open`) manifest as `NoSuchFileException` and are
|
||
treated as "event not in store anymore" — we skip it and keep going.
|
||
|
||
### Watch / subscribe (future)
|
||
|
||
`java.nio.file.WatchService` on `events/` gives per-platform
|
||
inotify/FSEvents/ReadDirectoryChangesW. Lets a future
|
||
`amy store tail` stream newly arriving events to stdout without
|
||
polling. Not part of this plan but the layout supports it
|
||
natively.
|
||
|
||
---
|
||
|
||
## Close / reopen
|
||
|
||
- `close()` releases any held `FileChannel` and flushes nothing
|
||
(writes are already durable as of each rename, modulo the
|
||
`AMY_FSYNC` option).
|
||
- Reopen is free: the store is stateless. No header page, no WAL
|
||
checkpoint. First action after open: scan `.staging/` and delete
|
||
leftovers (crash from previous run).
|
||
|
||
---
|
||
|
||
Next: NIP specifics and enforcement details
|
||
(`2026-04-24-file-event-store-nips.md`).
|