Ark.EctoCursor (ark v0.14.0)

Copy Markdown View Source

Keyset pagination for Ecto queries.

A cursor holds a sort order and the position of the last row read. Each page is fetched with a WHERE clause that continues right after that position, so pages stay consistent when rows are inserted or deleted between calls. With an index matching the sort order, the database seeks to the position in the index, so deep pages stay fast.

Create a cursor with a sort order and a page size, then fetch pages with a base query and a repo. Each page carries the cursor for the next one.

cursor = Ark.EctoCursor.new(order_by: [desc: :inserted_at, asc: :id], page_size: 20)

%Ark.EctoCursor.Page{entries: posts, next_cursor: next} =
  Ark.EctoCursor.fetch_page(cursor, MyApp.Post, MyApp.Repo)

%Ark.EctoCursor.Page{entries: more_posts} =
  Ark.EctoCursor.fetch_page(next, MyApp.Post, MyApp.Repo)

The :has_next_page field of Ark.EctoCursor.Page tells whether more rows follow the page.

To go through every row, stream/4 fetches the pages lazily as the stream is consumed.

Ark.EctoCursor.new(order_by: :id, page_size: 500)
|> Ark.EctoCursor.stream(MyApp.Post, MyApp.Repo)
|> Enum.each(&MyApp.Search.index_post/1)

Sort order

The :order_by option lists the fields that define the order of the rows. A plain field name sorts in ascending order, and {:asc, field} or {:desc, field} give an explicit direction, as in Ecto.Query.order_by/3.

Choose the sort fields with these rules in mind:

  • Together, the fields must identify a single row. End the list with a unique field, usually the primary key, to break ties between rows that share the other values. For instance [desc: :inserted_at, asc: :id].
  • The fields must be non-nullable columns. Rows with a NULL sort value can be skipped from the pages, and a row with a nil sort value raises an ArgumentError when it becomes the position of the next cursor.
  • An index on the same fields, in the same order and directions (or with all directions reversed), lets the database read each page directly from the index. For the example above, that is create index(:posts, ["inserted_at DESC", :id]) in a migration.

With several sort fields, the query also bounds the first field with a redundant >= (or <=) condition, which lets query planners seek the index from the position. The :use_index option of fetch_page/4, stream/4 and query/3 controls this behaviour.

Base queries

The base query can be any Ecto.Queryable: a schema, a {source, schema} tuple, or a query with where, join, select and distinct: true clauses. The cursor adds the order_by, the limit, and the conditions on the position, which shapes the base query:

  • The base query leaves out limit, offset and order_by. The limit comes from the :page_size option, and the order from the cursor.
  • Combinations such as Ecto.Query.union/2 go inside a subquery: from(s in subquery(union_all(q1, ^q2))).
  • distinct uses true rather than a list of expressions.

Each row returned by the query must be a map or a struct that holds every sort field, under an atom or string key. Rows selected as schema structs or with Ecto.Query.API.map/2 meet this naturally. With a custom select, include the sort fields in the selected map.

By default, sort fields are read from the root source of the query. To sort on a joined source, give the cursor a :binding naming a binding of the query, defined with the :as option. A joined binding needs an explicit select that returns the sort fields of that binding, under their field names. This example lists the members of a group, sorted by user name.

query =
  from m in MyApp.Membership,
    where: m.group_id == ^group_id,
    join: u in assoc(m, :user),
    as: :user,
    select: %{id: u.id, name: u.name, role: m.role}

cursor = Ark.EctoCursor.new(order_by: [:name, :id], page_size: 20, binding: :user)

Persisting cursors

A cursor is an embedded schema, so it can be stored in a column of a parent schema with Ecto.Schema.embeds_one/3. This is useful to resume a long running job, or to keep polling a table for new rows. The cursor of the last page still points after the last row, so with a sort order that follows insertion, such as :id or :inserted_at, later calls return the rows inserted since then. Concurrent transactions can commit rows out of that order though, and a row committed after a later row was read is skipped.

defmodule MyApp.Export do
  use Ecto.Schema

  schema "exports" do
    embeds_one :cursor, Ark.EctoCursor, on_replace: :delete
  end
end

The database column holds a map, declared with add :cursor, :map in the migration that creates the parent table.

Only the sort order and the position are stored, so resume!/2 turns a stored cursor back into a working one, with the page size and binding given again. It also accepts nil, which starts a new cursor for a parent that has none yet.

@cursor_opts [order_by: [asc: :inserted_at, asc: :id], page_size: 500]

def export_next_batch(%MyApp.Export{cursor: stored} = export) do
  cursor = Ark.EctoCursor.resume!(stored, @cursor_opts)

  %Ark.EctoCursor.Page{entries: posts, next_cursor: next} =
    Ark.EctoCursor.fetch_page(cursor, MyApp.Post, MyApp.Repo)

  :ok = MyApp.Storage.write_posts(posts)

  export
  |> Ecto.Changeset.change()
  |> Ecto.Changeset.put_embed(:cursor, next)
  |> MyApp.Repo.update!()
end

Stored as JSON, position values take their JSON form, a datetime becomes a string for instance. Ecto casts them back to the type of the sort fields when building the query, using the schema of the queried source. Use a base query with a schema, such as MyApp.Post or {"posts", MyApp.Post}, with cursors loaded from storage.

Ecto.embedded_dump/2 and Ecto.embedded_load/3 convert a cursor to and from JSON data for storage outside of a schema.

Summary

Functions

Runs the query for the page that follows the cursor position and returns an Ark.EctoCursor.Page.

Returns a new cursor positioned before the first row.

Returns the query for the page that follows the cursor position, without running it.

Prepares a stored cursor for a new page, or creates a new cursor from nil.

Same as resume/2, but returns the cursor directly and raises an Ark.EctoCursor.MismatchError when the sort orders differ.

Returns a lazy stream of all the rows that follow the cursor position.

Types

executor()

@type executor() :: module() | (Ecto.Query.t() -> [map()])

new_opts()

@type new_opts() :: [
  order_by: atom() | [atom() | {:asc | :desc, atom()}],
  page_size: pos_integer() | nil,
  binding: atom() | nil
]

query_opts()

@type query_opts() :: [page_size: pos_integer(), use_index: boolean()]

t()

@type t() :: %Ark.EctoCursor{
  binding: atom() | nil,
  order_by: Ark.EctoCursor.OrderBy.t(),
  page_size: pos_integer() | nil,
  position: [term()] | nil
}

Functions

fetch_page(cursor, q, repo, opts \\ [])

@spec fetch_page(t(), Ecto.Queryable.t(), executor(), query_opts()) ::
  Ark.EctoCursor.Page.t()

Runs the query for the page that follows the cursor position and returns an Ark.EctoCursor.Page.

%Ark.EctoCursor.Page{entries: posts, has_next_page: more?, next_cursor: next} =
  Ark.EctoCursor.fetch_page(cursor, MyApp.Post, MyApp.Repo)

The page entries are the rows returned by the query, in the cursor order. The next cursor points after the last entry, and keeps the page size and binding of the given cursor. When the page is empty, the next cursor is the given cursor.

The query selects one row more than the page size to tell whether another page follows, so :has_next_page is exact even when the last page is full.

The third argument runs the query. It is either an Ecto.Repo module, on which Ecto.Repo.all/2 is called, or a function that receives the query and returns a list of rows. A function gives control over the repo options or the shape of the rows.

Ark.EctoCursor.fetch_page(cursor, MyApp.Post, fn query ->
  MyApp.Repo.all(query, prefix: "tenant_42")
end)

Each row must be a map or a struct holding the sort fields, under atom or string keys.

The options are the same as for query/3. Raises an ArgumentError for invalid options, a base query that does not fit the cursor, or when the last row of the page does not hold the sort fields or holds a nil sort value.

new(opts)

@spec new(new_opts()) :: t()

Returns a new cursor positioned before the first row.

The sort order is normalized to a list of {direction, field} tuples.

iex> Ark.EctoCursor.new(order_by: [:score, desc: :id], page_size: 10)
%Ark.EctoCursor{order_by: [asc: :score, desc: :id], position: nil, page_size: 10, binding: nil}

See the "Sort order" section of the module documentation to choose the sort fields.

Options

  • :order_by - Required. A field name, or a list of field names and {direction, field} tuples where the direction is :asc or :desc. Field names without a direction sort in ascending order.
  • :page_size - A positive integer, the number of rows per page. This option can also be given to query/3, fetch_page/4 and stream/4.
  • :binding - The named binding of the query that holds the sort fields. Defaults to the root source of the query.

Raises an ArgumentError for invalid or unknown options.

query(cursor, q, opts \\ [])

@spec query(t(), Ecto.Queryable.t(), query_opts()) :: Ecto.Query.t()

Returns the query for the page that follows the cursor position, without running it.

The returned query adds the sort order, a limit of one page, and the conditions on the cursor position to the base query. It is useful to inspect the generated SQL or the query plan.

query = Ark.EctoCursor.query(cursor, MyApp.Post)
Ecto.Adapters.SQL.explain(MyApp.Repo, :all, query)

To read pages, use fetch_page/4, which also computes the next cursor.

Options

  • :page_size - A positive integer that overrides the page size of the cursor. Required when the cursor has no page size.
  • :use_index - A boolean. When true, and the cursor has a position and several sort fields, the query bounds the first sort field with a redundant >= or <= condition so the database can seek an index on the sort fields. Defaults to true.

Raises an ArgumentError for invalid options or when the base query does not fit the cursor, see the "Base queries" section of the module documentation.

resume(cursor_or_nil, opts)

@spec resume(t() | nil, new_opts()) ::
  {:ok, t()} | {:error, Ark.EctoCursor.MismatchError.t()}

Prepares a stored cursor for a new page, or creates a new cursor from nil.

{:ok, cursor} = Ark.EctoCursor.resume(stored_cursor, order_by: :id, page_size: 100)

The options are the same as for new/1. The :order_by option must match the sort order of the stored cursor, and the returned cursor keeps the stored position. The :page_size and :binding of the returned cursor come from the options, and default to nil when they are not given.

Returns {:error, %Ark.EctoCursor.MismatchError{}} when the sort orders differ, for instance after a change of the sort fields in the code that stored the cursor. Raises an ArgumentError for invalid or unknown options.

resume!(cursor, opts)

@spec resume!(t() | nil, new_opts()) :: t()

Same as resume/2, but returns the cursor directly and raises an Ark.EctoCursor.MismatchError when the sort orders differ.

cursor = Ark.EctoCursor.resume!(stored_cursor, order_by: :id, page_size: 100)

stream(cursor, q, repo, opts \\ [])

@spec stream(t(), Ecto.Queryable.t(), executor(), query_opts()) :: Enumerable.t(map())

Returns a lazy stream of all the rows that follow the cursor position.

Ark.EctoCursor.new(order_by: :id, page_size: 500)
|> Ark.EctoCursor.stream(MyApp.Post, MyApp.Repo)
|> Stream.map(&MyApp.Search.to_document/1)
|> Enum.each(&MyApp.Search.index/1)

The stream fetches one page at a time with fetch_page/4 as it is consumed, and ends after the last page. Each page runs as its own query, so rows inserted while the stream runs are included when they sort after the current position.

The arguments and options are the same as for fetch_page/4. They are validated when stream/4 is called, before any query runs.