Skip to content

feat: warn when Live search_after paging uses an unstable sort key#301

Merged
niemyjski merged 2 commits into
mainfrom
agents/capable-opossum
Jul 24, 2026
Merged

feat: warn when Live search_after paging uses an unstable sort key#301
niemyjski merged 2 commits into
mainfrom
agents/capable-opossum

Conversation

@niemyjski

Copy link
Copy Markdown
Member

Summary

Root-causes and fixes the class of bug behind OUT-17644: a read-modify-write job paged through ~74k contacts with sort: "_doc" and SearchAfterPaging() (Live mode), silently stopping after ~10.5k documents with no error, because _doc (Lucene's internal doc id) is only stable within a Point-In-Time -- index refreshes and segment merges renumbered it mid-run and invalidated the search_after cursor.

An exhaustive audit of the codebase found no built-in read-modify-write paths (PatchAllAsync, RemoveAllAsync, etc.) using _doc/_score sorts today -- they're all id-stable. This PR adds a guardrail so any future or caller-supplied query that does this gets a clear warning instead of a silent data-loss bug.

Changes

  • SearchAfterQueryBuilder now logs a warning when Live-mode search_after paging is combined with an unstable sort key (_doc or _score). No warning in PointInTime mode, since a frozen snapshot keeps those keys stable. A single pass over the sort fields resolves each field name once to check both the id-tiebreaker and the unstable-sort condition.
  • The logger is created once, via constructor injection (ILogger<SearchAfterQueryBuilder> with a NullLogger fallback), rather than fetched inline on every query build. ElasticQueryBuilder now accepts an optional ILoggerFactory and wires it up in RegisterDefaults(); Index.CreateQueryBuilder() passes Configuration.LoggerFactory through.
  • Added tests covering: warning logged for _doc/_score in Live mode, no warning in PointInTime mode, no warning for an explicit stable sort, and no warning when no explicit sort is given (implicit id tiebreaker only).
  • Documented the gotcha in docs/guide/querying.md with strongly-typed sort examples and a link to Elasticsearch's paginate search results docs: https://www.elastic.co/docs/reference/elasticsearch/rest-apis/paginate-search-results#search-after

Testing

  • dotnet build Foundatio.Repositories.slnx -- 0 errors/warnings
  • Foundatio.Repositories.Elasticsearch.Tests -- 684/684 passed
  • Foundatio.Repositories.Tests -- 152/152 passed
  • dotnet format --verify-no-changes -- clean
  • CI (Build workflow) -- green

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a guardrail to prevent silent data loss when using Elasticsearch search_after deep pagination in Live mode with unstable sort keys (_doc, _score). It introduces a warning at query-build time, wires logging into the query builder via DI, adds tests for the warning behavior, and documents the gotcha for consumers.

Changes:

  • Log a warning when Live-mode SearchAfterPaging() is used with an unstable sort key (_doc / _score), while avoiding warnings in Point-In-Time mode.
  • Plumb ILoggerFactory into ElasticQueryBuilder (and Index.CreateQueryBuilder) to create the SearchAfterQueryBuilder logger once.
  • Add test coverage and documentation clarifying safe/unsafe search_after sorting patterns.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/Foundatio.Repositories.Elasticsearch.Tests/ReadOnlyRepositoryTests.cs Adds tests asserting warning/no-warning behavior for Live vs PIT and stable vs unstable sorts.
src/Foundatio.Repositories.Elasticsearch/Queries/Builders/SearchAfterQueryBuilder.cs Implements detection of unstable sort keys in Live search_after paging and logs a warning.
src/Foundatio.Repositories.Elasticsearch/Queries/Builders/ElasticQueryBuilder.cs Adds optional ILoggerFactory support and wires a logger into SearchAfterQueryBuilder during default registration.
src/Foundatio.Repositories.Elasticsearch/Configuration/Index.cs Passes Configuration.LoggerFactory into ElasticQueryBuilder so repository-built query builders can log.
docs/guide/querying.md Updates examples to strongly-typed sorts and documents the _doc/_score + Live search_after pitfall with guidance.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 47b0c76f2f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/guide/querying.md
@niemyjski
niemyjski force-pushed the agents/capable-opossum branch from 47b0c76 to 284b392 Compare July 23, 2026 19:46

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 284b392d8a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@niemyjski
niemyjski force-pushed the agents/capable-opossum branch from 284b392 to 18383d1 Compare July 23, 2026 20:07

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 18383d1cef

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@niemyjski
niemyjski force-pushed the agents/capable-opossum branch from 18383d1 to 4a0530d Compare July 23, 2026 22:04
niemyjski and others added 2 commits July 23, 2026 23:18
Sorting by _doc or _score with Live-mode search_after paging is only
safe within a Point-In-Time snapshot: index refreshes and segment
merges can renumber/reorder these keys mid-paging, silently
invalidating the cursor so a subsequent page can come back empty
while documents remain (root cause of OUT-17644, where a
read-modify-write job paged and wrote the same index using
sort: "_doc" and lost ~63k of 73k documents with no error).

SearchAfterQueryBuilder.BuildAsync now resolves each sort field once
per query build to check both the id-tiebreaker and for these
unstable sort keys, logging a warning when found in Live mode (no
warning in PointInTime mode, where these keys are stable).

The logger is now created once via constructor injection instead of
being fetched per query build: ElasticQueryBuilder accepts an
optional ILoggerFactory and constructs SearchAfterQueryBuilder with a
real logger during RegisterDefaults(); Index.CreateQueryBuilder passes
Configuration.LoggerFactory through.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a warning callout to the Search After Paging section covering the
unstable _doc/_score sort key footgun, using strongly-typed sort
expressions in the examples and linking to Elasticsearch's own
paginate-search-results docs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@niemyjski
niemyjski force-pushed the agents/capable-opossum branch from 4a0530d to 4c195c7 Compare July 23, 2026 23:34

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c195c7b1b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


if (warnOnUnstableSort && !alreadyWarned && UnstableSortFields.Contains(fieldName))
{
var logger = (ctx.Options.GetElasticIndex() as IHaveLogger)?.Logger ?? NullLogger.Instance;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep warnings on the public index interface path

When a repository is configured with a custom IIndex implementation rather than the built-in Index base class, this cast fails and the warning is sent to NullLogger, even though IIndex already exposes Configuration.LoggerFactory. That makes the new Live search_after guardrail silently disappear for interface-based index implementations; use the interface's logger factory or inject a logger into the query builder instead of depending on IHaveLogger.

Useful? React with 👍 / 👎.

@niemyjski
niemyjski merged commit 5580407 into main Jul 24, 2026
6 checks passed
@niemyjski
niemyjski deleted the agents/capable-opossum branch July 24, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants