Internals

Query engine

The engine is volcano. Each operator implements Init and Next, and a row is a vector of term ids.

The planner does not run the query. It builds a tree. The execution engine asks a factory for the matching executor tree and pulls rows until Next returns false.

Projection
  Filter          age > 21
    NestedLoopJoin
      TripleScan   ?s a ub:Student
      TripleScan   ?s ub:name ?name
      TripleScan   ?s ub:age ?age

Patterns join left to right. The filter sits above the joins. ORDER BY sits under the projection when it is present. Projection, then distinct, then limit. INSERT DATA and DELETE DATA plan to one insert or delete node, which runs as ModifyExecutor (plan 4.6): it writes each triple to the store, which writes every index or none, and returns the number changed as its one row. If a later write throws, it takes back the triples the statement already changed, so a statement is atomic on both backends even without a log.

Triple scan

TripleScanExecutor calls TripleStore::Scan(pattern) and gets a TripleIterator. Bound positions must match. If the same variable occurs twice inside one pattern, the scan keeps a triple only when those positions are equal.

An empty pattern is one empty row, not zero rows. That is the identity input for a join that has nothing to bind yet.

Status

The scan runs against MemStore. The iterator is a snapshot of the matching triples. Destroying it does not unpin anything, because the memory store has no pages.

Nested-loop join

The join merges two rows by slot. Where both sides have a real id, the ids must be equal. Where one side is unbound, the other's id wins. The output width is the query's slot count, not the sum of the two children.

The current join full-scans the inner child for every outer row. It does not push the outer binding into the inner pattern. The result is still correct, because the merge rejects pairs whose shared slots disagree. The cost is a full inner scan even when the outer row has already named the subject.

Plan 4.2, done: the index nested-loop join. Its inner side must be one triple pattern, and it has no inner executor: for each outer row it fills the pattern from that row and asks the store, which picks the index whose order starts with the bound positions. With rebinding the nested loop reads the same triples; the separate operator skips the inner executor and the generic merge, and names the strategy in the plan. \set join inlj selects it; a join whose inner side is not a scan stays a nested loop. On the memory backend the probe is a filtered scan.

Plan 4.3, done: hash join. It reads the inner (right) side once into a table keyed by the ids of the variables both sides produce, then streams the outer side through it. A row whose key slot is unbound is not hashed; it is compared with every row of the other side. With no shared variable the key is empty and the join is a cross product. Building on the inner side is a stand-in until the optimizer can pick the smaller side. \set join hash selects it.

\set join is applied by Optimizer::ApplyJoinMethod, which swaps the plan's join nodes before the plan is explained or run, so \explain shows the operator that runs. The differential test runs every query file under all three joins.

Filter

Equality and inequality are numeric when both sides are numeric datatypes, and term-id identity otherwise. Ordered comparisons are numeric, or lexicographic on the canonical spelling when the kinds match. A type error is false, not an exception. The query continues.

Projection, distinct, limit, sort

Projection keeps the slots in the SELECT clause. SELECT * keeps every slot in first-appearance order.

DISTINCT is a set of projected rows, not a set of triples. Distinct before projection and distinct after projection answer different queries: two triples can share a name.

OFFSET skips, then LIMIT counts. The window preserves the child order. A small example used by the tests: objects arriving as 5, 5, 3, 1, 2, with offset 1 and limit 2, yield 5 then 3.

ORDER BY compares the way the filter does. Do not sort raw ids. Ids are assigned in intern order, which is not value order. A small run sorts in memory. A run that does not fit sorts externally through the buffer pool, so a spill is a page with a pageLSN, not a private fwrite.

Insert and delete executors

A duplicate insert adds nothing and allocates no second id. A delete of a missing triple deletes nothing. When the indexed store exists, write the dictionary first, then every index. If a later index insert fails, the earlier indexes and the dictionary must not show a half-written triple.

That is also why a crash in the middle of a multi-page insert needs the log. The executor's job is the logical update. ARIES's job is to finish it or remove it. See WAL and ARIES.

Pinned frames

The buffer pool exposes the pinned-frame count. After every query, and after every destroyed iterator, that count is zero. A guard left on a leaf fails the harness even when the rows were right.

Further reading

Graefe, "Query Evaluation Techniques for Large Databases", the volcano iterator and the three join algorithms.