defmodule EctoCursorBasedStream do @moduledoc """ Use this module in any module that uses `Ecto.Repo` to enrich it with `cursor_based_stream/2` function. Example: defmodule MyRepo do use Ecto.Repo use EctoCursorBasedStream end MyUser |> MyRepo.cursor_based_stream(max_rows: 100) |> Stream.each(...) |> Stream.run() """ import Ecto.Query @type cursor_based_stream_opts :: [ {:max_rows, non_neg_integer()} | {:after_cursor, term() | %{atom() => term()}} | {:cursor_field, atom() | [atom()]} | {:order, :asc | :desc} | {:parallel, boolean()} | {:prefix, String.t()} | {:timeout, non_neg_integer()} | {:log, false | Logger.level()} | {:telemetry_event, term()} | {:telemetry_options, term()} ] @doc """ Return a lazy enumerable that emits all entries from the data store matching the given query. In contrast to `Ecto.Repo.stream/2`, this will not use database mechanisms (e.g. database transactions) to stream the rows. It does so by sorting all the rows by `:cursor_field` and iterating over them in chunks of size `:max_rows`. ## Options * `:cursor_field` - the field or list of fields by which all rows should be iterated. **This field must have unique values. (Otherwise, some rows may get skipped.)** For performance reasons, we recommend that you have an index on that field. Defaults to `:id`. * `:after_cursor` - the value of the `:cursor_field` that results start. When `:cursor_field` is a list then`:after_cursor` must be a map where keys are cursor fields (not all fields are required). Useful when you want to continue streaming from a certain point. Any rows with value equal or smaller than this value will not be included. Defaults to `nil`. (All rows will be included.) * `:max_rows` - The number of rows to load from the database as we stream. Defaults to 500. * `:order` - Order of results, `:asc` or `:desc` Defaults to `:asc`. * `:parallel` - when `true` fetches next batch of records in parallel to processing the stream. Defaults to `false` as this spawns `Task`s and could cause issues e.g. with Ecto Sandbox in tests. * `:prefix, :timeout, :log, :telemetry_event, :telemetry_options` - options passed directly to `Ecto.Repo.all/2` ## Examples MyUser |> MyRepo.cursor_based_stream(max_rows: 1000) |> Stream.each(...) |> Stream.run() # change order, run in parallel MyUser |> MyRepo.cursor_based_stream(order: :desc, parallel: true) |> Stream.each(...) |> Stream.run() # change cursor field and set starting cursor MyUser |> MyRepo.cursor_based_stream(cursor_field: :email, after_cursor: "foo@bar.com") |> Stream.each(...) |> Stream.run() # with multiple fields MyUser |> MyRepo.cursor_based_stream(cursor_field: [:email, :date_of_birth], after_cursor: %{email: "foo@bar.com"}) |> Stream.each(...) |> Stream.run() # select custom fields, remember to add cursor_field to select MyUser |> select([u], map(u, [:my_id, ...]) |> select_merge([u], ...) |> MyRepo.cursor_based_stream(cursor_field: :my_id) |> Stream.each(...) |> Stream.run() # pass custom options to Ecto.Repo.all/2 MyUser |> MyRepo.cursor_based_stream(timeout: 60_000, prefix: "public") |> Stream.each(...) |> Stream.run() """ @callback cursor_based_stream(Ecto.Queryable.t(), cursor_based_stream_opts) :: Enum.t() defmacro __using__(_) do quote do @behaviour EctoCursorBasedStream @impl EctoCursorBasedStream def cursor_based_stream(queryable, options \\ []) do EctoCursorBasedStream.call(__MODULE__, queryable, options) end end end @doc false @spec call(Ecto.Repo.t(), Ecto.Queryable.t(), cursor_based_stream_opts) :: Enumerable.t() def call(repo, queryable, options \\ []) do %{after_cursor: after_cursor, cursor_fields: cursor_fields} = options = parse_options(options) Stream.unfold(nil, fn nil -> task = get_rows_task(repo, queryable, after_cursor, options) {[], task} task -> case options.task_module.await(task) do [] -> nil rows -> next_cursor = get_last_row_cursor(rows, cursor_fields) task = get_rows_task(repo, queryable, next_cursor, options) {rows, task} end end) |> Stream.flat_map(& &1) end defp parse_options(options) do max_rows = Keyword.get(options, :max_rows, 500) after_cursor = Keyword.get(options, :after_cursor, nil) cursor_field = Keyword.get(options, :cursor_field, :id) order = Keyword.get(options, :order, :asc) task_module = if Keyword.get(options, :parallel, false), do: Task, else: EctoCursorBasedStream.TaskSynchronous repo_opts = Keyword.take(options, [:prefix, :timeout, :log, :telemetry_event, :telemetry_options]) cursor_fields = validate_cursor_fields(cursor_field) %{ max_rows: max_rows, cursor_fields: cursor_fields, after_cursor: validate_initial_cursor(cursor_fields, after_cursor), order: order, repo_opts: repo_opts, task_module: task_module } end defp validate_cursor_fields(value) do cursor_fields = List.wrap(value) if Enum.all?(cursor_fields, &is_atom/1) do cursor_fields else raise ArgumentError, "EctoCursorBasedStream expected `cursor_field` to be an atom or list of atoms, got: #{inspect(value)}." end end defp validate_initial_cursor(_, nil) do %{} end defp validate_initial_cursor(cursor_fields, %{} = value) do {after_cursor, rest} = Map.split(value, cursor_fields) if map_size(rest) == 0 do after_cursor else raise ArgumentError, "EctoCursorBasedStream expected `after_cursor` to be a map with fields #{inspect(cursor_fields)}, got: #{inspect(value)}." end end defp validate_initial_cursor([cursor_field], value) when not is_list(value) and not is_tuple(value) do %{cursor_field => value} end defp validate_initial_cursor(cursor_fields, value) do raise ArgumentError, "EctoCursorBasedStream expected `after_cursor` to be a map with fields #{inspect(cursor_fields)}, got: #{inspect(value)}." end defp get_rows_task(repo, query, cursor, options) do %{cursor_fields: cursor_fields, order: order, max_rows: max_rows, repo_opts: repo_opts} = options order_by = Enum.map(cursor_fields, fn cursor_field -> {order, cursor_field} end) options.task_module.async(fn -> query |> order_by([o], ^order_by) |> apply_cursor_conditions(cursor_fields, cursor, order) |> limit(^max_rows) |> repo.all(repo_opts) end) end defp apply_cursor_conditions(query, _cursor_fields, cursor, _order) when map_size(cursor) == 0 do query end defp apply_cursor_conditions(query, cursor_fields, cursor, :asc) do conditions = cursor_fields |> zip_cursor_fields_with_values(cursor) |> Enum.reverse() |> Enum.reduce(nil, fn {field, value}, nil -> dynamic([r], field(r, ^field) > ^value) {field, value}, acc -> dynamic([r], field(r, ^field) >= ^value and (field(r, ^field) > ^value or ^acc)) end) where(query, [r], ^conditions) end defp apply_cursor_conditions(query, cursor_fields, cursor, :desc) do conditions = cursor_fields |> zip_cursor_fields_with_values(cursor) |> Enum.reverse() |> Enum.reduce(nil, fn {field, value}, nil -> dynamic([r], field(r, ^field) < ^value) {field, value}, acc -> dynamic( [r], field(r, ^field) <= ^value and (field(r, ^field) < ^value or ^acc) ) end) where(query, [r], ^conditions) end defp zip_cursor_fields_with_values(cursor_fields, cursor) do cursor_fields |> Enum.map(fn cursor_field -> {cursor_field, Map.get(cursor, cursor_field)} end) |> Enum.reject(&is_nil(elem(&1, 1))) end defp get_last_row_cursor(rows, cursor_fields) do last_row = List.last(rows) unless is_map(last_row) do select = Enum.map_join(cursor_fields, ", ", &inspect/1) raise RuntimeError, "EctoCursorBasedStream query must return a map with cursor field. If you are using custom `select` ensure that all cursor fields are returned as a map, e.g. `select([s], map(s, [#{select}]))`." end Map.new(cursor_fields, fn cursor_field -> case Map.fetch(last_row, cursor_field) do {:ok, value} -> {cursor_field, value} :error -> raise RuntimeError, "EctoCursorBasedStream query did not return cursor field #{inspect(cursor_field)}. If you are using custom `select` ensure that all cursor fields are returned as a map, e.g. `select([s], map(s, [#{inspect(cursor_field)}, ...]))`." end end) end end