atagen

macltok

public
Star0
Watch0

Commit cff1b0a4eb30

tarball

atagen <boss@atagen.co>2026-10-25 13:25 UTC

1 file changed+78-0
docs: record the search design
Modified file: docs/design-decisions.md+78 additions
@@ -463,6 +463,84 @@ most. a diff for a timeline with no reset is a divergence too, so a wrapper
463463 that swallowed the reset still heals. anything a late subscriber needs, failures
464464 included, lives in the state rather than only in an update
465465
466+## search
467+
468+`search_messages` takes words and a scope of one room, a list of them or every
469+joined room, and answers newest first. it opens a search the way a jump opens an
470+event context, a session owning the media of every hit it hands out under its
471+own bounded scope, paged with `search_more` and its cursor and freed with
472+`close_search`. at most eight stay open and opening another closes the oldest,
473+so a host that never closes anything still holds a bounded amount. a hit's
474+media ids stop resolving once its search closes. encrypted rooms can't use
475+the server's `/search`, so everything goes through one local index, unencrypted
476+rooms too, so results and coverage mean the same thing everywhere. a hit comes
477+back the way event context shows its target, latest edit applied, with byte
478+ranges of the matched words in its text
479+
480+the index is tantivy, but not the fork's matrix-sdk-search. that crate keeps its
481+schema private, keeps an index per room, opens a fresh `IndexWriter` for every
482+event, ranks by score, hands back bare event ids and turns mmap on, which wasm
483+can't build. `IndexWriter` can't run in wasm either, since it spawns indexing
484+threads and rayon pools. so macltok keeps its own segments. each flush writes an
485+immutable segment with `SingleSegmentIndexWriter` into a `RamDirectory`, edits
486+and redactions are tombstones in a bitset beside it, and segments of a size
487+class merge through `merge_filtered_segments` once six pile up, dropping the
488+tombstoned docs as they go. all of that is single threaded with the docstore's
489+dedicated compressor off. body text is indexed, not stored, a preview reads the
490+event back from the event cache or the server. a 2026-10-25 spike ran the
491+whole cycle in a headless firefox worker
492+
493+it lives with the store, per login and never synced. natively it's
494+`macltok-search.sqlite3` beside the sdk's databases, in a browser the
495+`::macltok-search` IndexedDB database, and both go when the store is deleted.
496+every segment, every tombstone generation and the manifest is a blob sealed by
497+a `StoreCipher` under the store passphrase, its own key sealed inside so blobs
498+can't be swapped. one commit is one transaction, so a crash keeps the last
499+commit whole. the working copy lives in memory, all of it, which is the price
500+of tantivy's directory being synchronous and IndexedDB not. a store that won't
501+open or read means an index in memory for this engine, and coverage says
502+`persistent: false`
503+
504+it's fed from what the client sees. each joined room's event cache is followed
505+from its first update, so sync, pagination and the redecryptor's replacements
506+all arrive as diffs, and a room idle for five minutes is let go so the cache can
507+shrink. an edit lands on its original under the original's key, the latest by
508+the original's sender winning, and one that arrives first holds the key
509+provisionally until the original turns up. a redaction drops the key and is
510+remembered for a while, so a stale copy can't bring it back. redacting the
511+latest edit reads the original again. a message still encrypted waits, raw, up
512+to two thousand of them, and is decrypted when a room key for its session
513+arrives
514+
515+backfill is `/messages` straight from the room, not through the event cache,
516+so it never grows a timeline someone has open. one request at a time across
517+every room, two seconds apart, a hundred events a page and ten pages a room per
518+engine run, round robin. a room walks back from the newest event to the start,
519+and resumes from its saved token next time. the first time a room shows up in
520+a run it also gets a head pass from the newest event back until it meets
521+something indexed, which picks up what happened while we were offline. failures
522+back off from a minute to fifteen. all of that is `EngineConfig`, and zero pages
523+a room turns backfill off
524+
525+coverage is per room, the oldest and newest indexed message, whether backfill
526+reached the start, and where backfill is at. it comes with every page
527+
528+the analyser splits on anything that isn't alphanumeric, lowercases and folds
529+accents, so `cafe` finds `Café`. it doesn't stem or segment cjk, a run of cjk is
530+one token. words all have to match, quotes make a phrase and `-` excludes.
531+tantivy's merger times itself with `Instant::now()` when a `log` logger is on at
532+debug, which panics in wasm. so in wasm macltok caps `log` at info at compile
533+time, `max_level_info` and `release_max_level_info` both, since in release the
534+release cap wins. a host asking for any other cap gets log's own "multiple
535+max_level_* features set" error rather than a quiet panic later
536+
537+a hit is read back when it's shown, `load_or_fetch_event` for the original and
538+for its latest edit, which is the event cache and its store first and the
539+server's `/event` after. one that's turned out redacted is dropped from the page
540+and from the index. one that can't be read at all, gone from the cache with the
541+server unreachable or refusing, is left out of that page but stays indexed, so
542+it shows up again once it can be read. the cursor still moves past it
543+
466544 ## the sdk fork
467545
468546 we build against a fork of matrix-rust-sdk, branch `macltok`, cut from the