ruby 75 lines · 4 tabs

Deterministic Sorting with Secondary Key

Shared by codesnips Jan 2026
4 tabs
class Article < ApplicationRecord
  scope :ranked, -> { order(score: :desc, id: :desc) }

  scope :after_cursor, ->(cursor) do
    return all if cursor.nil?

    where(
      "(articles.score < :score) OR (articles.score = :score AND articles.id < :id)",
      score: cursor.score,
      id: cursor.id
    )
  end

  def to_cursor
    Cursor.new(score: score, id: id)
  end
end
4 files · ruby Explain with highlit

When a list is sorted only by a column that has duplicate values — created_at timestamps that collide, a score shared by many rows — the database is free to return tied rows in any order. That non-determinism is invisible until it breaks pagination: a row can appear on two consecutive pages, or be skipped entirely, because the boundary between pages falls in the middle of a tie group. The fix is a deterministic total order, achieved by appending a unique secondary key (usually the primary key) to every ORDER BY so no two rows ever compare equal.

In Article model, the ranked scope encodes this rule: it orders by score DESC and then id DESC so the sort is stable and fully determined. The after_cursor scope implements keyset (seek) pagination against that exact ordering. Its WHERE clause uses row-value comparison logic — a row is "after" the cursor when its score is strictly smaller, OR its score ties and its id is smaller. This lexicographic condition mirrors the two-column ORDER BY precisely; the secondary key is what makes the boundary unambiguous.

The cursor itself is handled in Cursor value object. It packs the tuple [score, id] into an opaque, URL-safe Base64 token so clients treat it as a handle rather than guessable state. encode and decode round-trip the pair, and decode returns nil for malformed input so a bad cursor simply starts from the beginning instead of raising.

ArticlesController wires it together: it decodes the incoming params[:cursor], applies after_cursor when present, fetches one extra row to detect whether more pages exist, and emits a next_cursor built from the last visible record.

Keyset pagination this way is O(1) per page regardless of depth, unlike OFFSET which scans and discards skipped rows. The trade-off is that arbitrary page jumps are not supported — only next/previous relative to a cursor. It also requires a composite index matching the sort, shown in add_index migration as (score DESC, id DESC), so Postgres can satisfy both the ordering and the seek predicate with a single index scan. The common pitfall it avoids is forgetting the tie-breaker: without id in the ORDER BY and the WHERE, duplicate score values silently corrupt the result set.


Related snips

Share this code

Here's the card — post it anywhere.

Deterministic Sorting with Secondary Key — share card
Link copied