Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions docs/guide/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,15 +381,35 @@ More efficient for deep pagination:

```csharp
var results = await repository.FindAsync(
q => q.SortExpression("createdUtc"),
q => q.SortDescending(e => e.CreatedUtc),
o => o.SearchAfterPaging());

// For subsequent pages, use the token
var nextResults = await repository.FindAsync(
q => q.SortExpression("createdUtc"),
q => q.SortDescending(e => e.CreatedUtc),
o => o.SearchAfterToken(results.GetSearchAfterToken()));
```

> [!WARNING]
> **Avoid unstable sort keys (`_doc`, `_score`) with search after paging.** These keys are only
Comment thread
niemyjski marked this conversation as resolved.
> stable *within* a point-in-time. In the default `Live` mode the underlying `search_after` cursor
> can be invalidated by index refreshes or segment merges, causing pagination to **silently skip
> documents or stop early** — this is especially likely when you write to the same index you are
> paging over (read-modify-write). See Elasticsearch's own
> [paginate search results](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/paginate-search-results#search-after)
> guide for details on why `_doc` is only safe within a point-in-time. The repository logs a
> warning when it detects this. Sort by a stable, unique field, or open a point-in-time snapshot:
>
> ```csharp
> // Frozen view: _doc/_score stay stable and the cursor remains valid across pages.
> var results = await repository.FindAsync(
> q => q.SortDescending(e => e.CreatedUtc),
> o => o.SearchAfterPaging(SearchAfterPagingMode.PointInTime));
> ```
>
> Note: the repository always appends the document id as a tiebreaker, so a query with no explicit
> sort is safe. The danger is an explicit unstable sort key in `Live` mode.

## Aggregations

### Aggregation Expression
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@
using Foundatio.Repositories.Extensions;
using Foundatio.Repositories.Models;
using Foundatio.Repositories.Utility;
using Foundatio.Utility;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace Foundatio.Repositories.Elasticsearch.Configuration;

public class Index : IIndex
public class Index : IIndex, IHaveLogger
{
private readonly Lazy<IElasticQueryBuilder> _queryBuilder;
private readonly Lazy<ElasticQueryParser> _queryParser;
Expand Down Expand Up @@ -118,6 +119,7 @@ protected virtual void ConfigureQueryParser(ElasticQueryParserConfiguration conf
public ISet<string> AllowedAggregationFields { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public ISet<string> AllowedSortFields { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public IElasticConfiguration Configuration { get; }
public ILogger Logger => _logger;

public virtual string CreateDocumentId(object document)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
using Foundatio.Repositories.Models;
using Foundatio.Repositories.Options;
using Foundatio.Serializer;
using Foundatio.Utility;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace Foundatio.Repositories
{
Expand All @@ -25,6 +28,7 @@ public static class SearchAfterQueryExtensions
internal const string SearchBeforeKey = "@SearchBefore";
internal const string PointInTimeIdKey = "@PointInTimeId";
internal const string RepoOwnedPointInTimeKey = "@RepoOwnedPointInTime";
internal const string UnstableSortWarnedKey = "@SearchAfterUnstableSortWarned";

public static T SearchAfterPaging<T>(this T options, bool enabled = true) where T : ICommandOptions
{
Expand Down Expand Up @@ -183,10 +187,26 @@ namespace Foundatio.Repositories.Elasticsearch.Queries.Builders
/// adding the ID field for uniqueness, and reversing sorts for SearchBefore.
/// This builder runs last (Int32.MaxValue priority) so it sees all accumulated sorts.
/// </summary>
/// <remarks>
/// Also logs a warning when Live-mode search_after paging is combined with an unstable sort
/// key (<c>_doc</c> or <c>_score</c>), since those keys are only stable within a Point-In-Time
/// and can otherwise cause paging to silently skip documents or stop early. Because the same
/// <see cref="ICommandOptions"/> instance is reused across <c>FindResults.NextPageAsync()</c>
/// calls, the warning is only logged once per paging session (i.e. on the first page).
/// </remarks>
public class SearchAfterQueryBuilder : IElasticQueryBuilder
{
private const string Id = nameof(IIdentity.Id);

// Internal Lucene/relevance sort keys that are not stable across index refreshes or
// segment merges. Using them as a search_after cursor in Live paging mode can silently
// skip documents or terminate paging early while the index is being written to.
private static readonly HashSet<string> UnstableSortFields = new(StringComparer.Ordinal)
{
"_doc",
"_score"
};

public Task BuildAsync<T>(QueryBuilderContext<T> ctx) where T : class, new()
{
// Get sorts from context data (set by SortQueryBuilder or ExpressionQueryBuilder)
Expand All @@ -204,14 +224,60 @@ public class SearchAfterQueryBuilder : IElasticQueryBuilder
var resolver = ctx.GetMappingResolver();
string idField = resolver.GetResolvedField(Id) ?? "_id";

// Check if id field is already in the sort list
bool hasIdField = sortFields.Any(s =>
// Live search_after paging with an unstable sort key (e.g. _doc, _score) is only safe
// within a Point-In-Time: index refreshes and segment merges can invalidate the cursor,
// silently skipping documents or stopping paging early. Not applicable in PointInTime
// mode, where a frozen view keeps these sort keys stable.
bool warnOnUnstableSort = ctx.Options.GetSearchAfterPagingMode() is SearchAfterPagingMode.Live;

// The same ICommandOptions instance is reused across FindResults.NextPageAsync() calls,
// so BuildAsync runs once per page. Only warn on the first page to avoid flooding logs
// with the same warning for every page of a long-running paging session.
bool alreadyWarned = ctx.Options.SafeGetOption<bool>(SearchAfterQueryExtensions.UnstableSortWarnedKey, false);

// Single pass: resolve each sort's unstable field name (if any) once to check
// both for the ID tiebreaker and for unstable sort keys, avoiding redundant
// resolver calls. SortOptions is a discriminated union: _doc/_score can arrive
// either as a FieldSort with a literal field name, or as the ES client's typed
// Doc/Score variants, so both shapes need to be checked.
bool hasIdField = false;
foreach (var sort in sortFields)
{
if (s?.Field?.Field == null)
return false;
string fieldName = resolver.GetSortFieldName(s.Field.Field);
return fieldName?.Equals(idField) == true;
});
string? fieldName;
bool isIdField = false;

if (sort?.Field?.Field is { } sortField)
{
fieldName = resolver.GetSortFieldName(sortField);
if (fieldName is null)
continue;

isIdField = String.Equals(fieldName, idField, StringComparison.Ordinal);
}
else if (sort?.Doc is not null)
{
fieldName = "_doc";
}
else if (sort?.Score is not null)
{
fieldName = "_score";
}
else
{
continue;
}

if (isIdField)
hasIdField = true;

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 👍 / 👎.

logger.LogWarning("Sorting by {SortField} with Live search_after paging is unstable: {SortField} is not stable across index refreshes or segment merges, so the cursor can become invalid and paging may silently stop early (especially while writing to the index being paged). Sort by a stable, unique field or use SearchAfterPaging(SearchAfterPagingMode.PointInTime).", fieldName, fieldName);
ctx.Options.Values.Set(SearchAfterQueryExtensions.UnstableSortWarnedKey, true);
alreadyWarned = true;
}
}

if (!hasIdField)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Elastic.Clients.Elasticsearch;
using Exceptionless.DateTimeExtensions;
using Foundatio.Parsers;
using Foundatio.Repositories.Elasticsearch.Extensions;
using Foundatio.Repositories.Elasticsearch.Tests.Repositories.Models;
using Foundatio.Repositories.Exceptions;
using Foundatio.Repositories.Extensions;
using Foundatio.Repositories.Models;
using Foundatio.Repositories.Options;
using Foundatio.Repositories.Utility;
using Microsoft.Extensions.Logging;
using TimeZoneConverter;
Expand Down Expand Up @@ -987,6 +989,123 @@ public async Task FindWithSearchAfterPagingAsync()
} while (await findResults.NextPageAsync());
}

[Fact]
public async Task FindAsync_WithSearchAfterPagingLiveModeAndUnstableSortAcrossMultiplePages_LogsWarningOnce()
{
// Arrange
await _employeeRepository.AddAsync(EmployeeGenerator.Default, o => o.ImmediateConsistency());
await _employeeRepository.AddAsync(EmployeeGenerator.Generate(name: "Blake"), o => o.ImmediateConsistency());
await _employeeRepository.AddAsync(EmployeeGenerator.Generate(name: "Eric"), o => o.ImmediateConsistency());
int start = Log.LogEntries.Count;

// Act
// The same ICommandOptions instance is reused by NextPageAsync(), so BuildAsync runs once per
// page; the warning must only be logged once per paging session, not once per page.
var results = await _employeeRepository.FindAsync(q => q.Sort("_doc"), o => o.PageLimit(1).SearchAfterPaging());
int pageCount = 1;
while (await results.NextPageAsync())
pageCount++;

// Assert
Assert.True(pageCount >= 3);
Assert.Single(Log.LogEntries.Skip(start), l => l.LogLevel == LogLevel.Warning && l.Message.Contains("_doc") && l.Message.Contains("search_after"));
}

[Theory]
[InlineData("_doc")]
[InlineData("_score")]
public async Task FindAsync_WithSearchAfterPagingLiveModeAndUnstableSort_LogsWarning(string unstableSortField)
{
// Arrange
await _employeeRepository.AddAsync(EmployeeGenerator.Default, o => o.ImmediateConsistency());
await _employeeRepository.AddAsync(EmployeeGenerator.Generate(name: "Blake"), o => o.ImmediateConsistency());
int start = Log.LogEntries.Count;

// Act
var results = await _employeeRepository.FindAsync(q => q.Sort(unstableSortField), o => o.PageLimit(1).SearchAfterPaging());

// Assert
Assert.NotNull(results);
Assert.Contains(Log.LogEntries.Skip(start), l => l.LogLevel == LogLevel.Warning && l.Message.Contains(unstableSortField) && l.Message.Contains("search_after"));
}

[Theory]
[InlineData("_doc")]
[InlineData("_score")]
public async Task FindAsync_WithSearchAfterPagingPointInTimeModeAndUnstableSort_DoesNotLogWarning(string unstableSortField)
{
// Arrange
await _employeeRepository.AddAsync(EmployeeGenerator.Default, o => o.ImmediateConsistency());
await _employeeRepository.AddAsync(EmployeeGenerator.Generate(name: "Blake"), o => o.ImmediateConsistency());
int start = Log.LogEntries.Count;

// Act
// A Point-In-Time snapshot keeps the sort key stable across the paging session, so no warning is expected.
var results = await _employeeRepository.FindAsync(q => q.Sort(unstableSortField), o => o.PageLimit(1).SearchAfterPaging(SearchAfterPagingMode.PointInTime));

// Assert
Assert.NotNull(results);
Assert.DoesNotContain(Log.LogEntries.Skip(start), l => l.LogLevel == LogLevel.Warning && l.Message.Contains(unstableSortField) && l.Message.Contains("search_after"));
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task FindAsync_WithSearchAfterPagingLiveModeAndTypedScoreOrDocSort_LogsWarning(bool useDocSort)
{
// Arrange
// SortOptions is a discriminated union: Elasticsearch's client models an explicit "_doc" or
// "_score" sort as a typed Doc/Score variant (ScoreSort), not just as a FieldSort literally
// named "_doc"/"_score". The unstable-sort check must catch both shapes.
await _employeeRepository.AddAsync(EmployeeGenerator.Default, o => o.ImmediateConsistency());
await _employeeRepository.AddAsync(EmployeeGenerator.Generate(name: "Blake"), o => o.ImmediateConsistency());
int start = Log.LogEntries.Count;
var sort = useDocSort ? new SortOptions { Doc = new ScoreSort() } : new SortOptions { Score = new ScoreSort() };
string expectedField = useDocSort ? "_doc" : "_score";

// Act
var results = await _employeeRepository.FindAsync(
q => q.AddCollectionOptionValue<IRepositoryQuery<Employee>, SortOptions>(SortQueryExtensions.SortsKey, sort),
o => o.PageLimit(1).SearchAfterPaging());

// Assert
Assert.NotNull(results);
Assert.Contains(Log.LogEntries.Skip(start), l => l.LogLevel == LogLevel.Warning && l.Message.Contains(expectedField) && l.Message.Contains("search_after"));
}

[Fact]
public async Task FindAsync_WithSearchAfterPagingAndExplicitStableSort_DoesNotLogWarning()
{
// Arrange
await _employeeRepository.AddAsync(EmployeeGenerator.Default, o => o.ImmediateConsistency());
await _employeeRepository.AddAsync(EmployeeGenerator.Generate(name: "Blake"), o => o.ImmediateConsistency());
int start = Log.LogEntries.Count;

// Act
var results = await _employeeRepository.FindAsync(q => q.SortDescending(d => d.Name), o => o.PageLimit(1).SearchAfterPaging());

// Assert
Assert.NotNull(results);
Assert.DoesNotContain(Log.LogEntries.Skip(start), l => l.LogLevel == LogLevel.Warning && l.Message.Contains("search_after"));
}

[Fact]
public async Task FindAsync_WithSearchAfterPagingAndNoExplicitSort_DoesNotLogWarning()
{
// Arrange
await _employeeRepository.AddAsync(EmployeeGenerator.Default, o => o.ImmediateConsistency());
await _employeeRepository.AddAsync(EmployeeGenerator.Generate(name: "Blake"), o => o.ImmediateConsistency());
int start = Log.LogEntries.Count;

// Act
// No explicit sort means only the library's implicit id tiebreaker is used, which is stable.
var results = await _employeeRepository.FindAsync(q => q, o => o.PageLimit(1).SearchAfterPaging());

// Assert
Assert.NotNull(results);
Assert.DoesNotContain(Log.LogEntries.Skip(start), l => l.LogLevel == LogLevel.Warning && l.Message.Contains("search_after"));
}

[Fact]
public async Task FindAsync_WithSearchAfterPagingAndTrackTotalHitsDisabled_DoesNotReturnExactTotal()
{
Expand Down