Internals: the full note
Note
Synced from tie/docs/internals.md on 2026-10-28. Edit the source, not this copy.
This documents how tiedb stores triples in memory and on disk, and the memory
trade-offs that shaped the current design. It describes the code as it stands in
tiedb/ — treat it as a map for reading that package, not a spec to code against.
The triple model
Everything in a collection is a triple: (key, value1, value2). In the tagging
layer these read as (subject, relation, object), e.g.
hash tag sometag
hash tag sometag2
hash filename "myfile.ext"A query fetches all triples for a key (Get), and — when asked — also the
triples where the key appears as value2 (the reverse direction). Reverse
lookup is what turns sometag back into the set of hashes tagged with it.
“Triple” vs. “association” — two terms, two meanings
The code uses both words deliberately; they are not synonyms:
- A triple is the stored data record — the
Triplestruct(key, value1, value2). It is what gets serialized (toBytes), held in the arena/cache, and returned from queries as flat results:[]StringTriplefor the ordered page,[]Row(subject + its attributes) for the row-shaped reads. - An association is an index over triples — the structures that link entry
IDs so a triple can be found from one of its members.
associations/reverseAssociations,AssociationSet, andinsertAssociationare all index concepts. The subtree keyUniqueAssociation{AssociateTo, Relation}is a triple with the pivot entry factored out (the other two IDs).
Rule of thumb: if it holds or moves the full record, it’s a triple; if it
links or looks records up, it’s an association. So FileMod.Triple carries a
*Triple to the writer, while insertAssociation inserts that triple into the
association index.
Strings become IDs: the trie of unique values
Strings are never stored inline in the index. Each string is chopped into
SIZE_VALUE-byte (24 B) chunks and inserted into a per-level trie of unique
values. A string of length N occupies ceil(N/24) levels; the last chunk’s
entry ID is the string’s identity everywhere else.
Two red-black trees per level implement the trie (entryLevel in
datastructures.go). All index trees are lockedTree[K, V] — a typed
github.com/emirpasic/gods/v2 red-black tree behind an RWMutex, so keys and
values are stored unboxed (lockedtree.go):
entries:entryID (uint64)→*UniqueValue{ParentId, [24]byte chunk}uniqueValues:*UniqueValue{ParentId, chunk}→entryID(the reverse map, used to dedup and to resolve an existing string to its ID)
insert (collection.go) walks the chunks, reusing existing IDs where the
prefix already exists and minting new ones (nextID) otherwise. getValueString
walks back up ParentId links to reassemble the full string from an ID+level.
Consequence: a value shared by many triples (a media type, a tag name) is stored once. Long, unique values (paths, filenames) cost one entry per 24-byte chunk.
Whole-value hash entries (blob policy)
High-entropy values are the trie’s worst case: a 64-char hex content hash shares
no prefixes with any other, so it mints 3 fresh chunk entries (6 rbt nodes) and
the trie’s dedup buys nothing. A Collection may carry a BlobPolicy
(datastructures.go) that diverts such values out of the chunk trie into a
whole-value hash store — one id ↔ [32]byte entry instead of three chunks.
BlobPolicy.Encode(value) (raw []byte, ok bool)decides, deterministically on the value alone, whether a value is an opaque fixed-size blob and returns its raw bytes;Decodeis the inverse. Determinism matters: the same hash is theKeyof many triples, so inconsistent routing would mint two IDs for one value.- Only encodings of exactly 32 bytes take the hash path; anything else falls back to the trie, so the fixed 50-byte frame is never violated.
tiedbstays domain-agnostic — it only knows “some values are 32-byte blobs.”metadata.HexHashBlobPolicysupplies the content-hash implementation (reusingIsHexHash+hex), wired viaTieTree.SetBlobPolicyat DB construction.nil(the default) keeps the trie-only behavior, so existing collections and generictiedbusers are unaffected.
Hash entries carry the HASH_LEVEL sentinel instead of a real trie level, so
their associations live in dedicated hashAssociations/hashReverseAssociations
trees rather than a per-level entryLevel. Entry IDs are collection-global, so
keying those trees by ID never collides. getValueString reconstructs the string
via BlobPolicy.Decode when the level is HASH_LEVEL.
Associations: forward and reverse indexes
A triple’s association is stored keyed by entry IDs, not strings. Per level
(entryLevel):
associations:key (uint64)→*AssociationSetkeyed byUniqueAssociation{AssociateTo: value2, Relation: value1}→int64positionreverseAssociations:value2 (uint64)→*AssociationSetkeyed byUniqueAssociation{AssociateTo: key, Relation: value1}→int64position
The subtree (AssociationSet in associationset.go) maps
UniqueAssociation → int64. Query paths never rely on key ordering
(Sort re-sorts by resolved strings; intersect/exclude use point lookups),
so it is not an ordered tree — it picks the most compact of three
representations by size (see “Tiered representation” below). The int64 value
is a position in both modes:
- disk-backed mode: the byte offset of that triple’s record in the on-disk
file (
-1until the writer assigns it). The full(key, value1, value2)IDs are read back from disk on query, cached in a bounded LRU (triplecache.go) so hot triples avoid a re-read. - memory-only mode: an index into the collection’s in-memory
arena []Triple(collection.go). Queries never touch disk; there is no cache or file. Freed slots are recycled viaarenaFree, mirroring the diskfreespacelist.
Keeping the value a uniform int64 in both modes is deliberate: it keeps every
entry a fixed 16 bytes (UniqueAssociation + position) instead of a union type
sized to the larger Triple, which measured worse.
Both indexes hold the same position; the reverse index is a pure in-memory
acceleration structure, never persisted separately — it is rebuilt from the file
on load. resolveTriple turns a position back into a Triple uniformly (arena
lookup in memory mode, cache/disk read in disk mode) for the query path.
Tiered representation
AssociationSet grows through three representations, chosen by size, because
the workload is bimodal: a forward index keyed by unique content-address hashes
holds ~1 association per key, while a reverse index keyed by a shared value2
(e.g. a “file” relation) can hold millions under one key.
- inline — a single association is held in the struct’s
inlineKey/inlineValfields; nothing is allocated. This is the common forward-index case. - slice — on the second distinct key it grows into a flat
[]assocEntry(a{key, pos}pair per entry). For the small-to-medium sets that make up the bimodal middle this is the most memory-compact form: a Go map carries bucket and control-word overhead even when nearly empty (~272 B for a 2-entry map vs ~88 B for a 2-entry slice), and a linear scan over a handful of entries is as fast as hashing and stays in cache. Because no query relies on ordering,Deleteis a swap-remove. - sharded map — once a set exceeds
shardThreshold(256) it splits intoassocShards(16) independently-lockedmap[UniqueAssociation]int64shards. This is reached only by the reverse index’s shared-value hot sets, where two things matter: the fixed per-shard overhead (~1 KiB of mutexes + map headers) finally amortizes to a few percent, and — crucially — many concurrent load workers inserting into the same large set no longer serialize on one lock, since a key’s shard is chosen by hashing it.
The tiering is what makes the small-set case cheap: an earlier design allocated
the 16-shard array on the second key, so every 2-entry set paid the full
~1 KiB shard overhead — a large regression on a bimodal collection. Set algebra
(intersect/exclude, used by QueryTags) stays internal to tiedb.
Neither index is keyed by relation alone: forward is keyed by the subject,
reverse by the value. So “which subjects hold relation R, whatever the value”
has no index to answer it — the HasRelation predicate (query.go, #124)
walks the forward trees the way ForEachTriple does and keeps one entry per
subject carrying that relation. That is a full pass, the shape of an export
rather than a lookup, and it is still the cheaper place to answer the question:
the alternative is shipping every triple to the caller. It exists because
verify needs it — a directory node whose structural tie-type triple is gone
is invisible to a tie-type-driven universe, and the nodes that reference
nothing are invisible to an edge-driven scan too.
insertAssociation (collection.go) builds the forward node always, and the
reverse node only when shouldBuildReverse allows it (see below).
On-disk file format
The .tie file is a flat, append-oriented log of fixed-size records. Every
record is ENTRY_SIZE = 50 bytes, all integers little-endian:
ENTRY_SIZE = SIZE_DATATYPE(2) + SIZE_LEVEL(8) + SIZE_ID(8)
+ SIZE_PARENTID(8) + SIZE_VALUE(24) = 50The first 2 bytes are the record type. SIZE_VALUE is 24 specifically so an
entry record (2 + 8 + 8 + 8 + 24) fills the same 50-byte frame as an
association record (2 + 6×8).
Record types (datastructures.go):
TYPE_ENTRY(11) — one trie chunk. Layout:datatype | level | entryID | parentID | value[24]. Written byEntryToBytes, parsed bybufToEntry.TYPE_ASSOCIATION(12) — one triple. Layout:datatype | entry_level | entry_id(key) | value2_level | value2 | value1_level | value1. Written byTriple.toBytes, parsed bybufToAssociation. Note value2 precedes value1 on disk.TYPE_HASH(13) — one whole-value hash entry (see the blob policy above). Layout:datatype | level | entryID | raw hash[32]. The 32 raw bytes occupy the frame’s contiguousparentID(8) + value(24)region, soENTRY_SIZEis unchanged; the level field is alwaysHASH_LEVEL. Written byHashToBytes, parsed bybufToHash, loaded in pass 1 (it is an entry, not an association).TYPE_DELETE(10) — a tombstone. The writer overwrites the whole 50-byte frame with zeros andTYPE_DELETEin the first 2 bytes, then pushes the slot’s offset onto thefreespacestack for reuse by the next add.
Writes go through a single serialized writer goroutine (dBWriter in
filehandling.go) over dBWriteQueue. Slot allocation happens in the
producer (allocSlot): a reclaimed freespace slot when one exists, else
the db_size append cursor; the producer inserts into the in-memory index at
that final offset and the writer only persists bytes. A slot is freed by the
writer after its tombstone is on the file, so a reused slot is never handed
out while its old record is still live. The fd stays open for the collection’s
lifetime; durability is a 10-second Sync ticker plus a drain-and-sync on
closeDB. db_size must stay a multiple of ENTRY_SIZE; a partial trailing
record on open is trimmed as a torn write.
Forward/reverse index invariants
The forward index is authoritative — the file holds forward records only — and the reverse index is derived from it. Three rules keep them in step:
- Index sets are created atomically.
putAssocuseslockedTree.GetOrPut, so two concurrent inserters for a brand-new outer key (the first two triples of a fresh subject; the first two children of a fresh directory) share oneAssociationSet. A Get-then-Put used to let the second creator overwrite the first’s set, dropping its entry from one index while the other index — whose outer key already existed — kept it. The parallel load workers hit this on every start;TestLoadIndexConsistencypins it. Addis atomic with respect to dedup. The exists-check,allocSlotandinsertAssociationrun underchangeMutex(the lockDeleteholds), so two concurrent adds of one triple cannot both write a record — a second live record would resurrect the triple at the next load after a delete.- Resolved records are validated.
resolveEntrychecks that the record at an index position has the entry’s relation and one of its two members before a query emits it; on mismatch it evicts the cache line, re-reads once, then drops the entry with a warning. A slot reused after a stale cache line can no longer surface as another subject’s triple.
Delete removes the reverse entry even when the forward one is already gone,
so a reverse-only residue (“phantom”) can be cleared by an ordinary delete.
Collection.CheckIndex (exposed as the CheckIndex request, tie verify --index) walks both trees, reports every divergence, and optionally repairs
them in memory — see docs/verify.md.
Loading is two-pass
loadDB reads the whole file twice via loadPass:
loadEntriesinserts everyTYPE_ENTRY(and reclaimsTYPE_DELETEslots intofreespace), skipping associations.loadAssociationsinserts everyTYPE_ASSOCIATION.
The two passes are required, not incidental: insertAssociation →
shouldBuildReverse resolves a triple’s relation (value1) back to a string via
the entries trie to decide whether to build a reverse node. The loader runs
worker goroutines that consume records in arbitrary order, so an association can
be seen before the entry it references. Inserting all entries first guarantees
every relation string resolves during pass 2.
Each pass reads through one reusable buffer (reallocating it per read churned
gigabytes of garbage on a large file) and feeds a modestly-sized channel to the
workers; the reader copys each record into a fixed-size array before sending,
so buffer reuse is safe. loadPass fans out to several workers, and the GC
pacer is relaxed (debug.SetGCPercent) for the load’s duration — a bulk load
builds an almost-entirely-retained heap, so the default pace wastes cycles
re-scanning a live set that never shrinks.
Memory considerations
The dominant in-memory costs are (1) the trie entries and their two rbt nodes per chunk, and (2) the association indexes. For metadata-heavy stores the association indexes dominate, and historically each triple paid for two rbt nodes — one forward, one reverse — even when nothing ever queried that relation in reverse.
Three levers cut this ~45% at 1M triples (862 → 473 MiB, disk/reverse=all):
lazy single-entry association subtrees, opt-in reverse indexing (below), and
moving every tree from interface{}-boxed gods v1 to typed gods v2 generics
(lockedTree for the outer trees, AssociationSet for the subtrees). After the
v2 migration the remaining heap is genuine node structs and payload, not boxing.
The full optimization history — every lever, the real-db1.tie load/heap
measurements, and two by-value inlining experiments that were built, measured,
and reverted (both lost to Go map bucket slack) — is written up in
memory-optimization.md. Read it before attempting
another association-index memory change; the GiB-scale structural levers are
exhausted at the current baseline.
Two modes: memory-only vs disk-backed
NewDB(writeToDisk) selects the mode for every collection it creates:
- disk-backed (
writeToDisk=true): theint64position is a byte offset; theTripleis read from disk on query and served from a bounded LRU cache thereafter. Lowest resident memory. Adds are durable andSync()waits for the writer to flush them. - memory-only (
writeToDisk=false): theint64position indexes the in-memoryarena []Triple; queries never touch disk and there is no cache or file.Addinserts directly andSync()is a no-op. Fastest, higher memory, non-durable.
The two paths meet at resolveTriple, so Get/Sort are mode-agnostic. A
query walks the subtree in memory in both modes — there is no per-query
serialization; concurrent reads each use their own reply channel when a disk
read is needed.
Opt-in reverse associations
Most relations are never queried in reverse. In this codebase reverse lookup is
used only for tag (files with a tag), path (path → UID), and parent (a
directory’s children). Relations like filename, media-type, and size got a
reverse node that was pure overhead.
A collection now carries reverseRelations map[string]bool:
nil→ index every relation in reverse (the original behavior; kept as the default so generictiedbusers are unaffected).- non-nil →
insertAssociationbuilds a reverse node only for relations in the set;shouldBuildReversegates it.
Wiring: TieTree.SetDefaultReverseRelations sets a DB-wide default that
initialize copies into each new Collection (via Collection.SetReverseRelations);
WebserviceConfig.ReverseRelations plumbs it through NewWebservice. The
tie-triplestore sets {"tag", "path", "parent"}.
Effect: on a store with K metadata relations per item where only one is queried
in reverse, reverse-index nodes drop toward 1/K of the full set — e.g. 4
relations per file with a tag-only allowlist cut reverse nodes to ~25% of the
full index in a synthetic test.
Trade-off: on tie-triplestore, generic reverse queries (tie get -r <x>, and the
include/exclude tag algebra in QueryTags) resolve only for whitelisted
relations. Collections that never set an allowlist keep full reverse generality.
Notes for future work
- The reverse index is derived state — it can always be rebuilt from the file, which is what makes the two-pass load and the opt-in filter safe.
- Boxing (done): rbt keys/values used to be
interface{}and heap-boxed. All trees now use gods v2 generics (lockedTree[K,V],AssociationSet), so keys and theint64positions are stored unboxed. pprof after the migration shows no remaining boxing; the heap is node structs plus payload. Forking gods for a tighter node layout (dropping some of the child/parent/color pointer overhead) was considered and declined — the remaining per-node cost is small next to what boxing removal already banked. - Record layout: highwayhash keys used to be stored as 64-char hex → 3 trie
chunks per hash. The
TYPE_HASHrecord + blob policy (above) now stores them as one raw 32-byte entry, reusing the existing 50-byte frame (noSIZE_VALUEchange). This is opt-in viaSetBlobPolicy; a store written without the policy keeps hashes as trie chunks, and the two forms can coexist in one file since the record type gates interpretation. Migrate an old store to the compact form viatie dump→ restore into a policy-enabled DB. - A lazy reverse index (build a value’s reverse subtree on first reverse query and cache it) was considered as an alternative that keeps full generality at the cost of an O(n) first query; the opt-in approach was chosen because this codebase’s reverse queries hit a small, known set of relations.
Free-text search
Search is a derived read view, never stored. Collection.Search builds a
transient corpus from the forward-association index and the value trie:
- Candidate subjects come from the reverse
tie-typeindex when the request names tie-types (falling back to a full forward walk if that relation is not reverse-indexed). - For each candidate, the forward set names
(relation, value-id)pairs. The value string is reconstructed withlevelOf(valueID)+getValueString(level, valueID); the subject key withlevelOf(subjectID)+getValueString. Both are entry-trie walks, so the build reads no records and does not use the triple cache or the disk-read queue. - The corpus is cached per
(relations, tie-types)and tagged withsearchGen. Every user write (addLocked/deleteLocked, including Update/Set/batches) bumps the generation, and a repairingCheckIndexdoes too; bulk load does not, because the collection is not served until it finishes. A corpus is reused only while its revision matches, and stale corpora are dropped on the next search.
Matching is subject-level and supports words (cross-field, default),
substring, prefix and fuzzy. See search.md for the full
semantics, scoring and protocol.
Direct (.tie file) mode
tie -f <path.tie> (and client.Config.TripleStoreFile) opens one collection
without a triplestore process. The design reuses the server rather than
reimplementing it:
TieClient ── transport (Doer) ──┬─ webservice.Client (HTTP)
└─ client.localTransport
└─ webservice.Dispatch / DispatchStream
└─ api.RegisterAll handlers
└─ tiedbwebservice.Doeris the seam:Call/CallContextandDumpStreamdepend on it instead of*ws.Client. The HTTP implementation is the existing client; the local one marshals the same JSON request intoDispatch, which runs the registered handler against a localTieTree. There is no second query implementation to drift.WebserviceConfig.LocalDirmakescollectionKeymap<id>to<LocalDir>/<id>.tieand ignore the request namespace. Consequently<collection>_prevand any sibling collection work automatically.- Local construction goes through
webservice.NewWebservice, which installs the hash blob policy (HexHashBlobPolicy) the loader requires, and enables collection locking. - Reverse relations default to unset, i.e. every relation is indexed. The
reverse index is derived on load and is not stored in the file, so mirroring an
unknown server subset would silently lose results. The cost is memory/load
time, not correctness.
Config.ReverseRelations(CLI--reverse-relations) narrows the set for a very large file; a relation left out cannot be queried in reverse. tiedb.TieTree.SetReadOnly(true)(CLI--read-only, tie-smbReadOnly) opens collections with no writer goroutine and no lock: a private read-only fd,db_sizetrimmed to whole frames, no truncation. This lets a reader load while a mount or server holds the exclusive lock. Reads are a load-time snapshot; writes returntiedb.ErrReadOnlyandDispatchrefuses write requests with 403. The diagnostic index check (CheckIndexwithout repair) opts in throughSetReadOnlyGuard; a repair is refused. The reader does not see records appended after it loaded. POSIX shared locks are deliberately not used: a writer must holdLOCK_EX, soLOCK_SHwould block exactly when coexistence is wanted; the reader is lockless and its snapshot semantics are documented.tiedb.TieTree.SetLocking(true)takes an exclusiveflockon each loaded.tiefile. The server enables it by default and direct mode always does, so a running triplestore and atie -fcommand exclude each other; a second opener gets alocked by another processerror.DisableLockingexists for filesystems where flock is unreliable.- Auth is intentionally absent in-process: filesystem permissions on the file are the boundary. Read/write classification is still honored, so a read request cannot create a collection.
- The content-addressed filehost is not part of this:
-fis triples only. Uploads, downloads, imports and blob checks keep talking to[FileHosts.*]. tie dump --fileis the lightweight export sibling: it takes the same lock and sets the same blob policy, but indexes no reverse relations because a dump only walks forward triples.
Why triples, and why not just SQL
Triple stores are not novel — RDF/SPARQL, Datomic and friends have used the
(subject, predicate, object) shape for decades — and over time tiedb has
drifted toward looking like a relational database: the Query API grew
Terms/Exclude/Scope/Sort/Limit (a WHERE/ORDER BY/LIMIT in
disguise), and the Row type hands results back as {key, columns, values}.
It is fair to ask whether SQLite from day one would have been the smarter call.
Both of the following are true.
Where the triple model genuinely fits this project:
- Open schema. A new relation (
artist,tie-type, a tag invented tomorrow) needs noALTER TABLE— it is just another(key, value1, value2). For a personal media/tagging store where the “columns” are open-ended and user-defined, that avoids constant migrations. The SQL equivalent is an EAV table, which is a triple store with more ceremony. - Multi-valued by default. “Each cell is a list” is the point: a key can hold many values under one relation without a join table. The common case (N tags per file) is the default, not the special case.
- Reverse associations as a first-class index.
ReverseRelationsmakes “which files have tag X” as cheap as “which tags does file X have.” SQL offers the same via an index on a join table; here it is the core primitive, tuned for exactly this query pattern (see Associations). - Uniform storage/wire/backup. One record shape means
dump/restoreis trivial TSV, the crash-safe writer handles one kind of record, and the FUSE tree derives from the same triples — no per-table schema to version.
Where SQL would have been the easier path:
- We rebuilt a database. The crash-safe writer, freespace reclamation, sync durability, the association index, sort/pagination — SQLite provides all of that for free, hardened over twenty years. That is the real cost of the DIY engine.
- The
Rowtype is the tell. The moment clients wanted{key, columns, values}back, we admitted the consumption pattern is tabular even though the storage is triples.Rowoptimizes for the consuming client’s mental model (a wire-friendly, language-neutral record); the triple form optimizes for the engine’s (a subject and its predicate→object groups).Rowsits exactly on that boundary, which is why the name feels slightly off from inside tiedb. - Ad-hoc queries. Anything not pre-indexed in reverse is awkward; SQL’s query planner just handles new query shapes.
Synthesis. A defensible alternative is triples-as-the-logical-model over
SQLite-as-the-engine: a single triples(key, value1, value2) table with the
right indexes. That keeps every conceptual benefit above (open schema,
multi-valued, reverse lookups via a second index) while deleting most of tiedb —
the writer, freespace, durability, sort. The genuinely distinctive part of the
project was never the triple store; it is the content-addressed blob store +
FUSE tag tree combination, and that does not care whether metadata lives in
tiedb or SQLite. None of this argues for ripping tiedb out now: it works, it is
tuned for this repo’s memory constraints (memory is weighted ≥ load speed), and a
storage-engine rewrite is real risk for a mostly-ergonomic gain. It is recorded
here so the trade-off is a deliberate, remembered choice rather than an
accident.