Secondary Indexes and MapReduce
Objects are addressed by key, but you often need to find them by attribute: every user aged 42, every order placed in a date range. That is what secondary indexes (2i) are for. And once you can select a set of objects, you often want to aggregate them -- count, sum, sort, project. That is what MapReduce is for. The two compose: a MapReduce job can seed itself from a 2i query. This chapter covers both.
Secondary indexes (2i)
A secondary index attaches searchable (name, value) tags to an object
at write time. The index name's suffix picks its type:
_int- An integer index. Supports equality and range queries over integer values.
_bin- A binary index. Supports equality and range queries over byte-string values.
Attaching index entries
Over HTTP the entries ride in the object envelope or in X-Riak-Index-*
headers:
curl -X PUT http://127.0.0.1:8098/buckets/users/keys/alice \
-H 'Content-Type: application/json' \
-d '{
"value": "Alice",
"indexes": [
{"name": "age_int", "value": "42"},
{"name": "city_bin", "value": "seattle"}
]
}'
Over PBC the entries are RpbPair items in the RpbPutReq.indexes
field, one pair per entry, where the pair key names the index (with its
_int or _bin suffix) and the pair value carries the value bytes. A
Riak client library exposes this as add_index:
o = bucket.new('alice', data='profile')
o.add_index('age_int', '42')
o.add_index('city_bin', 'seattle')
o.store()
Querying an index
Two query types are supported, matching Riak:
# equality: every key whose age_int is exactly 42
hits = bucket.get_index('age_int', '42').results
# range: every key whose age_int is in [10, 50] inclusive
hits = bucket.get_index('age_int', '10', '50').results
Over PBC these are RpbIndexReq with qtype: 0 (equality) or
qtype: 1 (range, inclusive bounds). The current response returns a
single frame with done = true; streaming one frame per chunk is a
tracked follow-up.
Riak stored 2i entries in a 2i_partition_table. Dyniak stores them as
plain records inside the same Noxu environment as the primary data,
under three reserved key prefixes -- a primary record, a forward index
(name+value -> key) for the query path, and a reverse index
(key -> name+value list) so a delete or overwrite can clean stale
forward entries. A fixed-width length prefix on the value keeps prefix
scans unambiguous when value bytes contain the structural separator.
The full layout is in
Riak mode ops. The 2i
entries an object carries are written and removed atomically with the
object itself, including inside a transaction (see
Distributed Transactions).
MapReduce
MapReduce runs a pipeline of phases over a set of input objects. Each phase transforms a stream of values into another stream; the phases are chained, and the last phase's output is the job result. The shape is Riak's "pipe of phases."
Riak shipped a JavaScript (and Erlang) MapReduce engine so operators could ship arbitrary phase code inline. Dyniak takes a different bet: a fixed registry of named built-in phase functions written in Rust, plus optional sandboxed WebAssembly for custom phases. The built-ins cover the common jobs -- extract, count, sum, sort, union, project -- with no scripting engine to secure or slow down, and the WASM path (below) gives you custom logic when you need it without embedding a JavaScript runtime in the data plane. The rationale is in the crate's MapReduce module docs.
The job envelope
A job has two parts: inputs (the seed values) and query (the phase
list). Submit it to POST /mapred (HTTP) or RpbMapRedReq (PBC):
curl -s -X POST http://127.0.0.1:8098/mapred \
-H 'Content-Type: application/json' \
-d '{
"inputs": [["orders", "o-1"], ["orders", "o-2"], ["orders", "o-3"]],
"query": [
{"map": {"fn_name": "map_object_value"}},
{"reduce": {"fn_name": "reduce_sum", "keep": true}}
]
}'
Inputs can be an explicit list of (bucket, key) pairs (above), an
inline list of values, or a bucket name (all keys in the bucket,
enumerated by the executor).
Built-in phases
The built-in registry ships map and reduce functions named by Riak's
convention (map_*, reduce_*):
- map_object_value
- Extract the object's
valuefield. - map_object_value_list
- Emit each element if the value is a JSON array, else the scalar.
- map_extract_field
- Project a named field out of each object.
- map_identity
- Pass inputs through unchanged.
- reduce_count
- Count the inputs.
- reduce_sum
- Sum numeric inputs.
- reduce_sort
- Sort the inputs.
- reduce_set_union
- Union the inputs into a distinct set.
- reduce_identity
- Pass inputs through unchanged.
Two more phase kinds round out the pipeline:
Link-- follow object links, optionally filtered by bucket and tag. This is how link-walking is expressed; see Links and Link Walking.WasmModule-- run a registered WebAssembly module as a map or reduce phase. Covered below.
The data flow
flowchart LR
I[inputs:<br/>bucket/key pairs<br/>or a bucket] --> P1{Map phase}
P1 -->|values| P2{Reduce phase}
P2 -->|aggregated| O[result envelope]
subgraph pipe [pipeline: one task per phase, mpsc between phases]
P1
P2
end
A MapReduce pipeline. The executor runs one task per phase, wired by FIFO channels; the previous phase's output is the next phase's input, and the final phase's output is collected into the response. Built-in phases are pure and deterministic, so the same job over the same inputs is byte-identical across runs.
Because the built-in phases are pure Rust and the channels preserve FIFO order, a job is deterministic: run it twice against the same inputs and you get the same bytes.
Custom phases in WebAssembly
When a job needs logic the built-ins do not cover, a WasmModule phase
runs an operator-registered WebAssembly module as a map or reduce step.
This requires two things:
- The binary is built with the
wasmfeature. - The module is registered -- at startup via
riak.wasm_modules:(a list of{id, path}entries pointing at.wasmor.watfiles) or at runtime.
{
"inputs": ["events"],
"query": [
{"wasm_module": {"id": "sessionize", "kind": "map"}},
{"reduce": {"fn_name": "reduce_count", "keep": true}}
]
}
Without the wasm feature the phase is still parsed and validated, but
submitting a job that contains a WasmModule phase returns a
WasmNotImplemented error. Build with --features wasm and register
the module before you submit. The WASM executor shares its module store,
compilation cache, and resource limits with the custom-keyfun routing
described in Dyniak features ops.
Combining 2i, links, and MapReduce
The three tools compose into one pipeline. A job can start from a 2i query (find objects by attribute), walk their links (navigate the graph), and reduce the result (aggregate) -- for example, "sum the order totals of every customer in Seattle":
{
"inputs": {
"bucket": "customers",
"index": "city_bin",
"key": "seattle"
},
"query": [
{"link": {"bucket": "orders", "tag": "placed"}},
{"map": {"fn_name": "map_extract_field", "arg": "total"}},
{"reduce": {"fn_name": "reduce_sum", "keep": true}}
]
}
Read it as: select Seattle customers by 2i, follow each customer's
placed links into the orders bucket, extract each order's total,
and sum them. The 2i seeds the pipeline, the Link phase traverses,
and the map/reduce phases aggregate.
Where to next
- Links and Link Walking -- the
Linkphase in detail. - Full-Text, Vector, and Regex Search -- richer query than 2i's equality and range.
- Distributed Transactions -- writing an object and its index entries atomically.
- Dyniak wire protocols -- the exact MapReduce and index wire surface.