Dowser.Opensearch.Streamer (Dowser.Opensearch v0.1.0)

View Source

Turns a search into an Elixir Stream, for walking more documents than fit in one response.

%{query: %{match_all: %{}}, size: 1_000, sort: [%{"_id" => "asc"}]}
|> Dowser.Opensearch.Streamer.stream(index: "posts")
|> Stream.map(& &1["_source"])
|> Enum.each(&process/1)

Each element is a hit — _index, _id, _source and the rest — cast exactly as Dowser.Opensearch.Search.search/2 would cast it.

How it walks

A point in time pins the index against concurrent writes, and search_after pages through it, taking each page's cursor from the last hit of the page before it.

search_after is sequential by construction, so one stream cannot fetch pages in parallel. stream_slices/4 is how you get parallelism; see below.

The sort is yours to give

:sort is required in the query, and its last key must be unique per document.

This is the one place this module differs from dowser_elasticsearch, and the difference comes from OpenSearch rather than from taste. Elasticsearch has a _shard_doc sort field — shard index plus Lucene document id — that exists precisely to tiebreak a point-in-time walk, so the Elasticsearch streamer appends it for you and needs no sort from the caller. OpenSearch forked before _shard_doc was introduced and never added it, and there is nothing in its sort vocabulary that plays the same role: _doc is a per-shard document id, so the same value occurs once per shard, and search_after on a non-unique final key silently skips or repeats the documents that share it.

A guessed default would therefore be a walk that quietly loses documents on any multi-shard index, so this module asks instead. _id is always unique and needs no mapping change:

%{query: %{match_all: %{}}, sort: [%{"_id" => "asc"}]}

Sorting on _id loads its field data, which costs memory on a large index; where you have a unique, indexed field of your own — a monotonic id, a timestamp plus a tiebreaker — prefer it:

%{query: ..., sort: [%{"created_at" => "asc"}, %{"_id" => "asc"}]}

If your cluster does serve _shard_doc, pass it yourself — nothing here rejects it.

Options

  • :index — the index to open the point in time on. Required unless :pit is given. It is not sent with the searches themselves: OpenSearch rejects a search that names both an index and a PIT.
  • :pit — an existing point-in-time id to walk instead of opening one. It is checked once before the walk starts, and left open afterwards — whoever opened it closes it. Without it, the stream opens its own and closes it when enumeration ends, however it ends.
  • :keep_alive — how long OpenSearch holds the point in time, extended on every search. Defaults to "1m". Long enough to cover the gap between two pages, not the whole walk.
  • :verify_pit — whether a :pit you passed is checked before the walk starts. true by default; false when you just opened it and know it is alive.

Everything else is forwarded to Dowser.Opensearch.Search, so :context, :codec, :keys and :http_opts all work as usual.

A query with no size gets 1000, not OpenSearch's default of 10 — at 10 hits per round trip a million documents is a hundred thousand requests. Set your own when you have a reason to.

Slicing

stream_slices/4 splits the point in time into disjoint subsets and walks them at once. OpenSearch divides first across shards, then within each shard by contiguous ranges of Lucene document ids, so the natural ceiling is your shard count — more slices than shards subdivides a shard rather than adding parallelism.

%{query: %{match_all: %{}}, size: 1_000, sort: [%{"_id" => "asc"}]}
|> Dowser.Opensearch.Streamer.stream_slices(4, &Enum.count/1, index: "posts")
|> Enum.sum()

Note what it takes: a function, not a stream. Each slice is consumed inside its own task, because a lazy stream handed back out of a task would run every page in the caller — concurrent in name only.

To spread a walk across nodes instead, open the point in time yourself and give each node one slice. slice is an ordinary search body field, so it goes in the query rather than the options:

stream(%{query: ..., sort: ..., slice: %{id: 2, max: 8}}, pit: pit_id)

Failure

There is no stream!/2. Every other function in this package comes in a pair because it returns {:ok, result} or {:error, error} and the bang variant unwraps it; a stream has nothing to unwrap, and nothing has happened yet when it is built. Errors surface on enumeration, by raising — which is what the bang variant would have done anyway.

A missing :sort is the exception: that is a mistake in the call rather than a failure of the cluster, so it raises when the stream is built, not when it is walked.

The stream raises on the first failed request, and closes a point in time it opened on the way out — on normal completion, on Enum.take/2, and on an exception. It cannot close one if the enumerating process is killed outright; :keep_alive is the backstop there.

Summary

Functions

Streams every hit a search matches.

Walks slice_nbr slices of one point in time at once, running stream_fn over each.

Types

query()

@type query() :: map()

Functions

stream(query, opts \\ [])

@spec stream(query(), keyword()) :: Enumerable.t()

Streams every hit a search matches.

See the module documentation for the options, and for why :sort is required. The stream is lazy: nothing is requested, and no point in time is opened, until it is enumerated.

stream_slices(query, slice_nbr, stream_fn, opts \\ [])

@spec stream_slices(query(), pos_integer(), (Enumerable.t() -> term()), keyword()) ::
  Enumerable.t()

Walks slice_nbr slices of one point in time at once, running stream_fn over each.

Both are positional because both are required: there is no sensible default for how many slices to open — the useful number is your shard count, which this cannot know — and a function is what the whole call is for.

Note what it yields: one result per slice, not a stream of hits like stream/2. The hits are stream_fn's to consume.

stream_fn receives a slice's stream and is called inside the task that owns it, so the hits never cross a process boundary — which is the whole point: returning a lazy stream from a task would build it there and then run every page back in the caller.

%{query: %{match_all: %{}}, size: 1_000, sort: [%{"_id" => "asc"}]}
|> Dowser.Opensearch.Streamer.stream_slices(4, &Enum.count/1, index: "posts")
|> Enum.sum()

The result is a stream of whatever stream_fn returned, one per slice, so keep those small — a count, a sum, :ok. A slice that returns everything it read defeats the streaming.

A slice already in the query body is the base every slice is built on, so anything else the split needs travels with it; id and max are computed here and win:

stream_slices(%{query: ..., sort: ..., slice: %{field: "_id"}}, 7, &f/1, index: "posts")
# each walk carries %{"field" => "_id", "id" => 0..6, "max" => 7}

All slices share one point in time, opened here and closed when the stream finishes, however it finishes. Pass :pit to use one of your own; it is checked once, and left open.

Options

Takes everything stream/2 does, plus:

  • :max_concurrency — how many slices run at once. Defaults to slice_nbr, so every slice you asked for is actually in flight.

    Note this is not Task.async_stream/3's default of System.schedulers_online/0, nor capped by it. A slice spends its time waiting on OpenSearch, and a process blocked on a socket occupies no scheduler — so the limit that matters is what the cluster will take, not how many cores this machine has. Capping 32 slices at 10 schedulers makes the walk four times slower for nothing.

    Lower it when stream_fn is the expensive part rather than the fetching, or to be gentler on the cluster.

  • :timeout — per slice, not per request. Defaults to :infinity, since a slice runs as long as it takes to walk; Task.async_stream/3 would otherwise give up after five seconds.

  • :ordered — false by default, so a finished slice is not held back by a slower one.

A slice that fails raises: an exception from its own walk, or a RuntimeError naming the slice if the task exited or timed out.