This session covers examples/datalog/indexed.tur, which adds a hash-bucketed
EAVT index on top of the minimal database. The index maps (entity, attr)
pairs to the log positions of the matching datums, giving O(1) average-case
lookup for the most common query pattern.
Run the example on any engine:
./build/tur run examples/datalog/indexed.tur
./build/tur --interpret examples/datalog/indexed.tur
The minimal db-q scans every datum in the database for every query. With n
datums this is O(n) per query. For a blog with a thousand posts and ten thousand
comments, fetching Alice's posts requires scanning all ten-thousand datums just
to find the handful belonging to Alice.
An EAVT index pre-groups datums by (entity, attr). Answering "what is
Alice's name?" becomes O(1) average: hash (entity=1, attr=":user/name"),
scan the one bucket it lands in.
An index entry records one datum's key and where the datum sits in the log.
The index itself is a table of attribute names and a fixed table of 64
buckets, each a Vec of entries:
(defstruct IdxEntry :copy
[entity : int ;; the datum's entity
attr : int ;; the datum's attr-id
pos : int]) ;; the datum's index in the log
(defstruct EavtIdx :copy
[names : (Vec cstr) ;; attr-id -> attribute name
buckets : (Vec (Vec IdxEntry))]) ;; 64 buckets of entries
Storing the log position rather than a copy of the datum keeps an entry three
words, whatever a datum holds. EavtIdx is :copy because it is a pair of
handles: copying it copies the handles, not the tables, so every copy sees the
same index.
The bucket key uses an attr-id, a small integer the index hands out per distinct attribute name, instead of the name itself. Names are compared by contents once, when they are interned; the buckets compare integers from then on:
(defn attr-find [idx : EavtIdx attr : cstr] : (Option int)
(let [^mut i 0
^mut found (:: (none) (Option int))]
(while (< i (vec-len (.names idx)))
(when (cstr-eq? (vec-get (.names idx) i) attr)
(set! found (some i)))
(set! i (+ i 1)))
found))
(defn attr-intern [idx : EavtIdx attr : cstr] : int
(match (attr-find idx attr)
(Some id) id
(None) (do (vec-push! (.names idx) attr)
(- (vec-len (.names idx)) 1))))
Inserting interns the name, so a new attribute gets the next id. A lookup only
finds it: asking about an attribute the index has never seen answers None
and an empty result, without adding the name.
It is tempting to key on the cstr's address instead, and compare those
integers. Do not: whether two identical string literals share one address is
up to the C compiler. gcc merges them within a file, so the compiled program
happens to work; tur jit hands the same C to c2mir, which gives each literal
its own storage, and every lookup comes back empty. Turmeric gives a cstr no
identity -- = does not even accept one -- so compare names by their contents,
once, as attr-find does.
The bucket is chosen by:
(defn eavt-bucket [idx : EavtIdx entity : int attr : int] : (Vec IdxEntry)
(vec-get (.buckets idx) (mod (+ (* entity 31) attr) eavt-nbuckets)))
where eavt-nbuckets is 64. The hash is intentionally minimal -- this is a
tutorial, not a production hash map.
eavt-idx-insert! interns the attribute and appends an entry to its bucket:
(defn eavt-idx-insert! [idx : EavtIdx entity : int attr : cstr pos : int] : nil
(let [id (attr-intern idx attr)]
(vec-push! (eavt-bucket idx entity id) (IdxEntry entity id pos))
nil))
Appending is amortized O(1) and keeps each bucket in insertion order, which is log order.
eavt-idx-lookup scans the one bucket the key hashes to, collecting the log
positions of the entries that match (entity, attr) exactly:
(defn eavt-idx-lookup [idx : EavtIdx entity : int attr : cstr] : (Vec int)
(let [result (:: (vec-new) (Vec int))]
(match (attr-find idx attr)
(Some id)
(let [bucket (eavt-bucket idx entity id)
^mut i 0]
(while (< i (vec-len bucket))
(let [e (vec-get bucket i)]
(when (and (= (.entity e) entity) (= (.attr e) id))
(vec-push! result (.pos e))))
(set! i (+ i 1))))
(None) nil)
result))
The entity and attribute are both checked because different keys can land in
the same bucket. With 64 buckets and well-distributed data, each bucket holds
on average n / 64 entries. For the tutorial scale (dozens of datums) this is
O(1) in practice.
idb)The indexed database is the log and its index, kept in step:
(defstruct Idb :copy
[db : Db
idx : EavtIdx])
(defn idb-new [] : Idb
(Idb (db-new) (eavt-idx-new)))
idb-assert! appends to the log and indexes the new datum's position:
(defn idb-assert! [idb : Idb entity : int attr : cstr value : Value] : int
(let [tx (db-assert! (.db idb) entity attr value)]
;; The datum is the last entry in the log
(eavt-idx-insert! (.idx idb) entity attr (- (db-count (.db idb)) 1))
tx))
idb-q-ea is the indexed fast path for entity+attribute queries. It turns
the positions the index returns into a fresh Vec of the datums:
(defn idb-q-ea [idb : Idb entity : int attr : cstr] : (Vec Datum)
(let [positions (eavt-idx-lookup (.idx idb) entity attr)
result (:: (vec-new) (Vec Datum))
^mut i 0]
(while (< i (vec-len positions))
(vec-push! result (db-ref (.db idb) (vec-get positions i)))
(set! i (+ i 1)))
(vec-free positions)
result))
idb-free frees the buckets, both tables and the log, in that order.
| Operation | Minimal (db-q) | Indexed (idb-q-ea) |
|---|---|---|
| Find Alice's name | O(n) scan | O(1) avg hash lookup |
| Find all users | O(n) scan | O(n/64) per bucket |
| Insert a fact | O(1) append | O(1) append + O(1) hash insert |
| Memory | datums only | datums + index entries |
For the tutorial dataset (8 datums), the difference is imperceptible. For a
database with one million datums, idb-q-ea returns results in microseconds
while db-q would take milliseconds.
The demo in indexed.tur inserts three users and then does indexed lookups.
print-values! prints each datum's value and frees the result:
(idb-assert! idb 1 ":user/name" (StrVal "Alice"))
(idb-assert! idb 1 ":user/age" (LongVal 30))
(idb-assert! idb 1 ":user/email" (StrVal "alice@example.com"))
...
;; Indexed lookup: entity=1, attr=:user/name
(print-values! (idb-q-ea idb 1 ":user/name"))
;; Entity 3 has no email -- should return empty result
(let [results (idb-q-ea idb 3 ":user/email")]
(println (vec-len results)) ;; => 0
(vec-free results))
Expected output, from all three engines:
-- idb-q-ea entity=1 :user/name --
Alice
-- idb-q-ea entity=2 :user/age --
25
-- idb-q-ea entity=3 :user/email (should be empty) --
0
Hash collisions -- the 64-bucket table degrades to O(n/64) per bucket as n grows. A production implementation would rehash when the load factor exceeds a threshold (typically 0.75).
No AEV or VAE index -- the tutorial only implements the EAVT index. For
queries like "find all entities with attribute :user/name" you still need a
linear scan. An AEV (Attribute-Entity-Value) index would accelerate
attribute-only queries.
No retraction awareness -- the index stores all asserted datums, including
those later retracted. idb-q-ea may return datums that have been logically
retracted; callers must apply retracted? from query.tur if needed.
A linear name table -- attr-find scans the names, which is fine for a
schema's few dozen attributes and would want a hash table in a real database.
Load factor -- modify the index to track the number of inserted entries. Print a warning when the load factor (entries / buckets) exceeds 1.5.
AEV index -- add a second index that maps attr -> [datum] to support
idb-q-a [idb attr]. Where does it go in the Idb struct?
Retraction-aware lookup -- write idb-q-ea-current [idb entity attr]
that uses the EAVT index to get candidates and then filters out any datum
whose entity+attr has a :db/retract fact.
Benchmark -- insert 1 000 datums with random entity/attr combinations
and compare the time for 1 000 db-q calls vs idb-q-ea calls, timing
each loop with monotonic-ns from stdlib/time.tur.
The five example files form a progression:
| File | Concepts | Lines |
|---|---|---|
minimal.tur |
Value ADT, Datum, Database, db-q, combinators | ~180 |
query.tur |
value-eq?, q-av, db-as-of, pull, history, retraction | ~240 |
indexed.tur |
EAVT hash index, attr-ids, idb, idb-assert!, idb-q-ea | ~240 |
blog.tur |
Multi-entity domain, EntityVal references, Option lookups | ~240 |
datalog.tur |
Term ADT, Binding, pattern matching, join via clause chains | ~270 |
Each file is self-contained and runs on all three engines:
./build/tur run, ./build/tur jit or ./build/tur --interpret with
examples/datalog/<file>.tur.