Lesson 8 — Joins #
Traversal (Lesson 5) walks edges inside ONE collection. A
join spans TWO: it answers
questions a single collection cannot — orders whose customer is
gold-tier, where the tier lives in a different collection.
Joins need a running node (the gateway orchestrates the two sides,
which is why the wasm playground does not host them), so this lesson
is curl against your
Lesson 1 node — no live
widget. Like traverse, join stays DSL-side outside the
SQL M1 subset.
The shape to keep in mind is a semi-join — the SQL intuition is:
SELECT * FROM orders
WHERE org_id IN (SELECT _key FROM orgs WHERE tier = 'gold')
executed as one request, with both sides living in different collections.
The use case #
Facts and dimensions live in different collections because they change differently — orders stream in continuously, org tiers trickle. The question spans both. The questions this lesson answers:
- “Orders whose org is gold-tier” — the semi-join, Part 1’s walkthrough.
- “Documents visible only to members of group X” — entitlement filtering: the same shape, the group set lives in another collection.
- “Can I trust the join to mean what the schema says?” — declared
referencesmake contradicting joins fail closed (Part 2). - “One tenant dwarfs the others — where do their writes land?” —
FK
routingand salting (Part 2).
Part 1 — the undeclared join #
Steps 1–3 build the two collections and run the join with nothing declared — two keyword fields are all a join requires. The sections that follow show what actually executed, where a join may sit, and the guardrails.
Step 1 — create the two collections #
Nothing exotic: two keyword fields are enough for a join to work.
PUT /orgs
{
"schema": { "dynamic": "strict", "properties": {
"tier": { "type": "keyword" }
}}
}
PUT /orders
{
"schema": { "dynamic": "strict", "properties": {
"status": { "type": "keyword" },
"org_id": { "type": "keyword" }
}}
}
Step 2 — seed a known answer #
Two gold orgs, one silver, and ten orders spread over them:
PUT /orgs/_doc/org-a { "tier": "gold" }
PUT /orgs/_doc/org-b { "tier": "silver" }
PUT /orgs/_doc/org-mega { "tier": "gold" }
PUT /orders/_doc/a-1 { "status": "new", "org_id": "org-a" } # … a-2, a-3
PUT /orders/_doc/b-1 { "status": "new", "org_id": "org-b" }
PUT /orders/_doc/m-1 { "status": "new", "org_id": "org-mega" } # … m-2 … m-6
Step 3 — join #
The URL stays on the outer collection (orders); the join clause
names the inner one:
POST /orders/_search
{
"size": 10,
"track_total_hits": true,
"query": {
"join": {
"target": "orgs",
"query": { "term": { "field": "tier", "value": "gold" } },
"join_on": "_key",
"field": "org_id"
}
}
}
9 hits — the three a-* orders and the six m-* orders, because
org-a and org-mega are gold. b-1 is missing: org-b is silver.
Change the inner term to silver and exactly b-1 comes back. The
outer side never saw the tier — it only saw the resolved key set.
What actually executed #
The work splits in two, and neither half fan-outs blindly:
- Gateway — ran the inner
termonorgs, distributed across its shards, and collected the matching_keys (org-a,org-mega). These join keys attach to the join node as a prepared payload. - Engine — each
ordersshard received the keys and ran the join as a local membership filter onorg_id.
graph LR
Q["POST /orders/_search
join target: orgs"] --> GW("gateway")
GW -->|"1. run inner query
term tier=gold"| O("orgs
the inner side")
O -->|"2. extract join keys:
org-a, org-mega"| GW
GW -->|"3. keys ride the join node
as a prepared payload"| E("orders shards
the outer side")
E -->|"4. membership filter
on org_id"| H["9 hits"]
Two collections, one request, no application-side fan-out. Repeated joins amortize: an identical inner request re-issued by auto-fired patterns is served from a dimension cache validated against exact per-shard visibility tokens — any write, failover or restart invalidates it. Never a TTL, never a stale hit.
Joining on values, not keys #
join_on names where the keys come from on the inner side — it does
not have to be _key. Any inner field works: its values are extracted
from each inner hit (scalars verbatim, arrays expand to one key per
element) and matched against the outer field:
POST /invoices/_search
{
"query": {
"join": {
"target": "orgs",
"query": { "term": { "field": "tier", "value": "gold" } },
"join_on": "code",
"field": "org_code"
}
}
}
orgs.code values (say "A-100") become the key set; invoices
matching on their own org_code keyword field survive. This shape
cannot ride the fast _ref column path below — that path exists only
for joins that follow a declared references (join_on: "_key",
field the FK field).
Where a join may sit #
- Inside
bool.must,bool.filterandnested— resolved and executed, composed with ordinary clauses like any filter:
POST /orders/_search
{
"query": {
"bool": {
"filter": [
{ "term": { "field": "status", "value": "new" } },
{ "join": {
"target": "orgs",
"query": { "term": { "field": "tier", "value": "gold" } },
"join_on": "_key",
"field": "org_id"
}}
]
}
}
}
- Inside
bool.shouldorbool.must_not— inert. An unresolved join node matches nothing, so a join undershouldquietly contributes no hits. Put joins inmust/filter. - Inside another join’s inner query — rejected with a
400(no join-of-joins).
Scoring and guardrails #
score_mode controls how inner-query scores reach the outer document:
none (the default — pure filter, inner scores discarded), max,
sum, or avg. In v1 only the _key rung carries scores; the
field-targeted rungs are filter-shaped and reject other values with a
400.
The guardrails are fail-closed, not silent:
inner_hits_cap(default 10 000) bounds the inner query. When the inner hit set reaches the cap the key set may be truncated — the join result is then a lower bound, logged at preflight, never silently complete. Raise the cap if the dimension side is legitimately larger.- Point-in-time — a join under a PIT requires the PIT to cover
both collections (same pinned snapshot on each side); otherwise a
400, not a pinned outer mixed with a realtime inner. - Namespace sharing — the inner query runs in the outer request’s namespace: one tenant’s authorization covers both collections.
400for a missing target collection, afieldabsent from the outer schema, a non-keywordfield, or a join contradicting a declaredreferences;503when the target has no started primary shards.
Add "explain": true to a request and the response narrates the join
plan — which locality tier it took and, when a better tier was
skipped, why.
Part 2 — declaring the relationship #
The schema in Step 1 already works. Declaring what org_id means
buys three things: joins that are checked (a contradicting request
fails instead of silently joining wrong), a faster execution path,
and FK-routed placement (an org’s orders colocate on one partition
and become addressable by key). This part rebuilds orders with the
declaration and walks each consequence — every step runs on your
Lesson 1 node, and nothing outside this page is
required.
Step 4 — rebuild orders with the declaration
#
A collection’s schema is fixed at creation, so drop and recreate
orders — only ten documents to rewrite:
DELETE /orders
PUT /orders
{
"schema": { "dynamic": "strict", "properties": {
"status": { "type": "keyword" },
"org_id": {
"type": "keyword",
"immutable": true,
"references": { "collection": "orgs" },
"routing": true,
"ref": true
}
}}
}
Then re-run the ten order writes from Step 2 — orgs is untouched.
What each parameter buys:
references— declares the foreign key:org_idnames a document key inorgs. Writes do not fail when that document is missing (a dangling reference still lands; the join simply does not match it). The declaration also pins the join’s shape — Step 5 shows the400.immutable— a prerequisite ofrouting(a mutable routing key would re-route documents), enforced on the update path: an update that re-points or drops the field fails with a400— moving a document between routing values is a delete + re-create, never a silent drift.ref— materialize the reference at write time into a hidden_<field>_refnumeric column that joins ride instead of resolving keys. Declare it before ingesting: only documents carrying a materialized_refmatch (older documents need a reindex).routing— placement routes by the field’s value:hash(org_id) % partitionspicks the home partition, not the document key’s hash. Step 6 makes this visible.
Step 5 — the same join, checked and faster #
Re-run the Step 3 join verbatim — same 9 hits, different execution.
With "ref": true the engine resolves org_id through the hidden
_ref column instead of filtering terms. The full rung ladder: a
field of "_key" rides the outer key registry; a references field
with "ref": true rides the hidden column; any other declared keyword
field runs engine-side as an exact terms filter — and a references
without "ref": true still joins correctly, degrading to that
keyword rung with a warning in the server log saying so. Add
"explain": true and the response narrates the join plan — which
locality tier it took, and why when a better one was skipped.
The orchestration keeps Part 1’s shape — gateway, then outer shards — but steps 2–4 changed vocabulary:
graph LR
Q["POST /orders/_search
the same join, declared"] --> GW("gateway")
GW -->|"1. run inner query
term tier=gold"| O("orgs
the inner side")
O -->|"2. resolve each key
to one packed integer id"| GW
GW -->|"3. refs ride the join node:
integers, not strings"| E("orders shards
the outer side")
E -->|"4. numeric filter on the hidden
_org_id_ref column"| H["same 9 hits"]
The declaration is enforced, fail-closed. The join’s target and
join_on: "_key" must now match what org_id declares — point the
join somewhere else and it is rejected rather than silently joining
through a field the schema says means something else:
POST /orders/_search
{
"size": 10,
"query": {
"join": {
"target": "orders",
"query": { "term": { "field": "status", "value": "new" } },
"join_on": "_key",
"field": "org_id"
}
}
}
400 — the join contradicts the declared references on org_id.
Step 6 — placement you can see in the write response #
Look back at the responses your Step 4 writes returned (or run one again):
{ "result": "created", "_id": "0,17" }
The system id is rolling,seq — two numbers; the exact values depend
on your node’s write history. The home partition reads straight off
the id: seq % partition_count. The routing value is what picks that
partition — with routing: true a keyed write lands at
hash(org_id) % partitions, and the seq is then allocated on that
partition to match — so a-1, a-2, a-3 share one partition,
b-1 lands on another, and the six m-* funnel into a third: an
org’s orders are colocated, and the hot org’s dominance is already
visible in the ids.
graph LR subgraph pa["partition of hash(org-a)"] a1("a-1") a2("a-2") a3("a-3") end subgraph pb["partition of hash(org-b)"] b1("b-1") end subgraph pm["partition of hash(org-mega)"] m1("m-1 … m-6") end
The routing field is load-bearing — a write without it has no home partition:
PUT /orders/_doc/bad-1 { "status": "x" }
400 — and null, arrays and objects are refused the same way.
Step 7 — by-key addressing with ?routing=
#
On a routed collection a key alone has no home partition either: key-addressed operations take the routing value:
GET /orders/_doc/a-1?routing=org-a # 200 — the document
GET /orders/_doc/a-1 # 400 — bare key, guidance in the body
GET /orders/_doc/a-1?routing=org-b # 404 — honest: org-b's partition was probed
HEAD, UPDATE and DELETE take the same parameter:
POST /orders/_update/a-1?id_type=key&routing=org-a
{ "doc": { "status": "shipped" } }
The fetch reference and the partial update reference cover the full key-addressing vocabulary.
Step 8 — salting the hot org #
org-mega carries 6 of the 10 orders — all on one partition.
Salting splits a hot routing
value across a SET of partitions, and it is two-step by design: the
feed measures, you assign.
Set a threshold, then read the feed:
PUT /orders/_salting
{ "threshold": 4, "assignments": {} }
GET /orders/_salting/feed?size=10
The feed is a terms aggregation over the routing field:
org-mega (6 docs) crosses the threshold and is flagged
candidate: true; org-a (3 docs) is not. Nothing is salted yet —
candidate flagging is measurement, assignments are intent. Assign
four splits:
PUT /orders/_salting
{ "threshold": 4, "assignments": { "org-mega": 4 } }
The response renders the computed partition set — the frozen engine
contract hash("org-mega" #i) % partitions, sorted and deduped (the
effective spread can land below the requested splits; the response
reports what IS):
{
"acknowledged": true,
"routing_field": "org_id",
"threshold": 4,
"status": "assigned (salt-split routing active on writes)",
"assignments": [ { "value": "org-mega", "splits": 4,
"partitions": [76, 83, 85, 254] } ]
}
The hot value’s placement, before and after the assignment:
graph LR
subgraph before["before — every org-mega write funnels here"]
HOT("org-mega's single partition
m-1 … m-6")
end
subgraph after["after — the assigned SET"]
S1("partition 76")
S2("partition 83")
S3("partition 85")
S4("partition 254")
end
HOT -.->|"new writes pick a member by hash(_key) % 4"| S1
HOT -.-> S2
HOT -.-> S3
HOT -.-> S4
Write six more orders for the hot org:
PUT /orders/_doc/m-7 { "status": "salted", "org_id": "org-mega" } # … m-8 … m-12
The response _ids now spread across the assigned set — each keyed
write picks its member deterministically
(hash(value #hash(_key) % splits)), so the same key always lands in
the same place while the org stops funneling every write into one
partition. Unassigned values (org-a, org-b) keep their single
partition, and key-addressed reads still work — they probe the whole
set:
GET /orders/_doc/m-3?routing=org-mega
Step 9 — placement changed, visibility did not #
Re-run the Step 3 join: 15 hits now — the three a-* orders plus
twelve m-*. Search fans out across every partition, so the split
placement is invisible to queries; salting changes placement, never
join visibility.
Clearing an assignment narrows placement on the very next write. The PUT is full-replace — restate whatever stays:
PUT /orders/_salting
{ "threshold": 4, "assignments": {} }
Documents written before an assignment stay on their old partitions until a rehash migration moves them — the salting reference covers the contract, the migration story, and the limits.
What you learned #
joinis a semi-join: the inner query resolves to a key set, the outer collection filters on it — two collections, one request, no application-side fan-out.- The inner side is any query;
join_oncan be_keyor any inner field (values, not keys). - Joins belong in
must/filter; they are inert undershould/must_notand cannot nest. - Guardrails fail closed: capped joins report lower bounds, PITs must
cover both sides, malformed shapes are
400s. - Declaring
references/immutable/ref/routingbuys checked joins (fail-closed400s), the hidden_refcolumn path, and FK-routed placement with?routing=key addressing — and salting splits a hot value across a partition set without touching join visibility. Part 2 walked all of it against your own node.
Next: Lesson 9 — operations.