Problem
OpenSearch Point-In-Time (PIT) search contexts are pinned to specific (node, shard, contextId) tuples encoded in the PIT id. When a node restarts (e.g., during an automated rolling kernel upgrade), every search context held on that node is gone. There is no server-side replica fallback for PIT searches: once a PIT id is established, queries execute against the exact contexts encoded in the id, and a missing context surfaces as a shard failure.
For deployments with mandatory rolling restart schedules, any long-running PIT-paginated workload (reindex, export, sync) that crosses a restart window fails or returns silently incomplete results.
Why this has to live in the client
Server-side fixes (replica fallback, context migration) are not on the OpenSearch roadmap and would change PIT's snapshot semantics. The fix has to live in the client.
The fix is conceptually simple: when a PIT is invalidated, recreate it and resume from the last search_after cursor. The cost is a narrow window of weakened snapshot semantics around the restart boundary. For pagination, ETL, and reindex-style workloads this is acceptable; for strict snapshot reads it is not, so the resilient path must be explicit and observable.
Failure modes (server response shapes)
Three shapes matter for client-side classification:
- Partial shard failure (most common during rolling restart). HTTP
200, _shards.failed > 0, _shards.failures[].reason.type == "search_context_missing_exception". Other shards' hits are still in hits.hits. This is what you see when one node out of N is mid-restart.
- Total context loss. HTTP
404, top-level error.type == "search_context_missing_exception". Every referenced node has lost its contexts.
- Transport / 5xx. Connection reset, node-gone, etc. PIT state is unknown until probed (
size=0 against the PIT id).
Server source references:
server/src/main/java/org/opensearch/search/SearchContextMissingException.java
server/src/main/java/org/opensearch/action/search/SearchContextId.java
libs/core/src/main/java/org/opensearch/core/rest/RestStatus.java (HTTP status aggregation across shards)
Proposed design
A Paginator on the PIT subclient that owns PIT lifecycle and transparently recreates the PIT on classified failures, resuming via search_after.
Versioning
- v4 (
opensearchapi): API surface added; resilience knobs ship zero-valued. No automatic recreates unless the caller opts in. Forward-compatible experimentation path; today's behavior unchanged.
- v5preview (
v5preview/opensearchapi): PIT methods relocated from top-level Client onto a client.PIT subclient (matching v4's shape). Paginator ships with sane defaults: keep-alive 5m, max recreate attempts 3, capped exponential backoff, hit-id dedup window 32768.
Iterator surface (Go 1.23+ iter.Seq2)
The paginator iterates over hits or pages depending on configuration. Hit-level iteration is the default because it enables _id-based deduplication across snapshot breaks; page-level iteration is offered for callers that batch per-page.
p, err := client.PIT.Paginator(ctx, PaginateReq{
Indices: []string{"my-index"},
Body: body, // must include a deterministic Sort
})
if err != nil { /* ... */ }
defer p.Close(ctx)
// hit-level (recommended)
for hit, err := range p.Hits(ctx) {
switch {
case errors.Is(err, pit.ErrHitOutOfOrder):
// hit is still valid; surface to ops, then process or skip
metrics.OutOfOrder.Inc()
process(hit)
case err != nil:
return err // fatal
default:
process(hit)
}
}
// or page-level
for page, err := range p.Pages(ctx) {
switch {
case errors.Is(err, pit.ErrSnapshotBreak):
// page is valid; previously-paginated pages may be stale relative
// to the new snapshot. Caller decides: log, restart, or mark partial.
log.Warn("snapshot break", "old_pit", page.PriorPITID, "new_pit", p.PITID())
batchWrite(page.Hits.Hits)
case err != nil:
return err
default:
batchWrite(page.Hits.Hits)
}
}
Snapshot-break semantics
Across a recreate boundary the paginator cannot guarantee strict ordering or exactly-once delivery. Two sentinel errors surface the anomaly without forcing the caller to abort:
ErrSnapshotBreak (Pages). Returned alongside the first page emitted from a recreated PIT. Signals: "previously-paginated pages may be stale; the new snapshot includes writes since the old PIT was created, and any of those writes that would belong on a page already past the cursor will not be visible to this paginator." Caller decides: log, restart from scratch, or mark the workload partial downstream.
ErrHitOutOfOrder (Hits). Returned alongside a hit that was already seen by _id in a prior PIT but reappears with a different version after a recreate. The hit value is the newer version. Caller decides: process the new version, skip, or surface to ops.
ErrShardFailure (Hits and Pages). Returned alongside a hit/page that came from a search response with _shards.failed > 0 for a non-PIT-context reason (circuit breaker, task cancelled, etc.). The cursor has advanced past the failed shard's slice, so docs from that shard at this cursor range are lost. Caller decides: tolerate (mark partial) or abort. The paginator does not retry or recreate, because PIT pins to specific contexts and a non-context shard failure is not a snapshot problem.
Note: both errors are returned with a valid V value. This is a deliberate departure from "err != nil means V is zero" and is documented in the package doc. Fatal errors (context cancellation, exhausted recreate budget, transport failure that does not classify) still return a zero V.
The caller picks one of two delivery modes:
- Skip-on-dedup (default for
Hits). Maintain a bounded LRU set of recently-emitted _ids and skip duplicates after a recreate, surfacing ErrHitOutOfOrder for the version-changed case. Trade: may omit documents whose sort values shifted across the boundary and now fall behind the cursor.
- Emit-all. Disable dedup. Trade: may emit duplicates and out-of-order documents around the boundary.
Pages cannot dedup individual hits and inherits emit-all by default; only ErrSnapshotBreak applies.
Observability
Two layers, matching the rest of the client:
Counters and gauges via the existing client.Metrics() snapshot. The PIT paginator increments fields on the internal opensearchtransport.metrics struct; new fields on the public Metrics snapshot:
// added to opensearchtransport.Metrics
PITsOpened int `json:"pits_opened"` // counter
PITsClosed int `json:"pits_closed"` // counter
PITsRecreated int `json:"pits_recreated"` // counter
PITHitsEmitted int64 `json:"pit_hits_emitted"` // counter
PITPagesEmitted int64 `json:"pit_pages_emitted"` // counter
PITShardFailures map[string]int `json:"pit_shard_failures"` // counter, keyed by reason.type
The "currently open PITs" gauge is PITsOpened - PITsClosed, computed at scrape time. PITShardFailures is keyed by reason.type (e.g., search_context_missing_exception, circuit_breaking_exception) so callers can split PIT-related from transient. Callers wire client.Metrics() into Prom/OTel via their own existing scraper; we do not take a dependency on either library.
Per-event callback for rich context. OnSnapshotBreak (already on PITPaginatorConfig) fires once per recreate with old/new PIT id, cursor, failed-shards detail, cause, and attempt number. This is the hook ops uses when a counter spike needs to be cross-referenced with a specific incident. Counters alone do not carry that context.
Configuration
type PITPaginatorConfig struct {
KeepAlive string // v5 default "5m"; v4 required
MaxRecreateAttempts int // v5 default 3; v4 default 0
Backoff func(attempt int) time.Duration // v5 default capped exponential; v4 default nil
SeenIDsWindow int // v5 default 32768; v4 default 0
OnSnapshotBreak func(SnapshotBreakEvent) // nil-safe
}
type SnapshotBreakEvent struct {
OldPITID string
NewPITID string
Cursor []any
FailedShards []ShardSearchFailure
Cause error
Attempt int
}
Memory footprint for the dedup window. A bounded LRU implemented as map[string]struct{} plus a circular buffer of _id strings (cheaper than container/list, same API surface). Per-entry cost on 64-bit Go: ~16 B string header + ~20 B string data (default OpenSearch auto-generated _id is 20 chars; see TimeBasedUUIDGenerator.java, 15-byte UUID base64-encoded without padding) + ~32 B amortized map slot + ~16 B ring slot ≈ ~75-80 B per entry. At the v5 default of 32768 entries, steady-state is roughly 2.4 MiB per fully-loaded paginator.
Custom _id schemes shift this:
| pattern |
length |
est. per entry |
32K total |
| OpenSearch auto-gen |
20 |
~75 B |
~2.4 MiB |
| ULID |
26 |
~82 B |
~2.6 MiB |
| RFC 4122 UUID string |
36 |
~92 B |
~2.9 MiB |
| Composite keys (timestamp + source + key) |
60-100 |
~110-150 B |
~3.5-5 MiB |
Adjust SeenIDsWindow for high-cardinality jobs, long custom _ids, or memory-constrained processes. A bloom filter would lower the per-entry cost (and admit false positives, which here means rare missed dedups, i.e., emitting a duplicate the caller could have skipped); deferred as an optimization until profiling justifies it.
Lifecycle
The paginator creates and deletes the PIT by default. WithExistingPIT(id) is an escape hatch for callers sharing a PIT across runs; in that mode the paginator does not delete on Close.
Out of scope
- Request hedging / latency-driven retries. Caller owns
ctx; timeout-driven concurrency is the caller's responsibility.
- Server-side changes (replica fallback, context migration).
- Caller-supplied error classifier. Classification is internal until a concrete use case justifies exposing it.
Open questions
- Default backoff. Proposing capped exponential with full jitter:
delay = jitter(min(250ms * 2^attempt, 10s)). Rationale: PIT recreate is not retrying a hot query against an overloaded cluster, it's recreating a fresh id that picks up surviving replicas, so the backoff exists to absorb classifier flakiness (e.g., a probe race between the old PIT id partially expiring) rather than to wait out a node restart. Three attempts with this curve totals at most ~14s of wall time before giving up. Tunable via Backoff func(attempt int) time.Duration for callers with stricter SLOs.
WithExistingPIT + MaxRecreateAttempts > 0 interaction. If the caller hands us a PIT id and we recreate it on failure, the new id is not the one they manage. Options: (a) refuse the combination at construction time, (b) recreate freely and surface the new id via OnSnapshotBreak so the caller can update their bookkeeping, (c) recreate but mark the paginator detached from the original id. Leaning toward (b); flagging for review.
Problem
OpenSearch Point-In-Time (PIT) search contexts are pinned to specific
(node, shard, contextId)tuples encoded in the PIT id. When a node restarts (e.g., during an automated rolling kernel upgrade), every search context held on that node is gone. There is no server-side replica fallback for PIT searches: once a PIT id is established, queries execute against the exact contexts encoded in the id, and a missing context surfaces as a shard failure.For deployments with mandatory rolling restart schedules, any long-running PIT-paginated workload (reindex, export, sync) that crosses a restart window fails or returns silently incomplete results.
Why this has to live in the client
Server-side fixes (replica fallback, context migration) are not on the OpenSearch roadmap and would change PIT's snapshot semantics. The fix has to live in the client.
The fix is conceptually simple: when a PIT is invalidated, recreate it and resume from the last
search_aftercursor. The cost is a narrow window of weakened snapshot semantics around the restart boundary. For pagination, ETL, and reindex-style workloads this is acceptable; for strict snapshot reads it is not, so the resilient path must be explicit and observable.Failure modes (server response shapes)
Three shapes matter for client-side classification:
200,_shards.failed > 0,_shards.failures[].reason.type == "search_context_missing_exception". Other shards' hits are still inhits.hits. This is what you see when one node out of N is mid-restart.404, top-levelerror.type == "search_context_missing_exception". Every referenced node has lost its contexts.size=0against the PIT id).Server source references:
server/src/main/java/org/opensearch/search/SearchContextMissingException.javaserver/src/main/java/org/opensearch/action/search/SearchContextId.javalibs/core/src/main/java/org/opensearch/core/rest/RestStatus.java(HTTP status aggregation across shards)Proposed design
A
Paginatoron the PIT subclient that owns PIT lifecycle and transparently recreates the PIT on classified failures, resuming viasearch_after.Versioning
opensearchapi): API surface added; resilience knobs ship zero-valued. No automatic recreates unless the caller opts in. Forward-compatible experimentation path; today's behavior unchanged.v5preview/opensearchapi): PIT methods relocated from top-levelClientonto aclient.PITsubclient (matching v4's shape). Paginator ships with sane defaults: keep-alive5m, max recreate attempts3, capped exponential backoff, hit-id dedup window32768.Iterator surface (Go 1.23+
iter.Seq2)The paginator iterates over hits or pages depending on configuration. Hit-level iteration is the default because it enables
_id-based deduplication across snapshot breaks; page-level iteration is offered for callers that batch per-page.Snapshot-break semantics
Across a recreate boundary the paginator cannot guarantee strict ordering or exactly-once delivery. Two sentinel errors surface the anomaly without forcing the caller to abort:
ErrSnapshotBreak(Pages). Returned alongside the first page emitted from a recreated PIT. Signals: "previously-paginated pages may be stale; the new snapshot includes writes since the old PIT was created, and any of those writes that would belong on a page already past the cursor will not be visible to this paginator." Caller decides: log, restart from scratch, or mark the workload partial downstream.ErrHitOutOfOrder(Hits). Returned alongside a hit that was already seen by_idin a prior PIT but reappears with a different version after a recreate. The hit value is the newer version. Caller decides: process the new version, skip, or surface to ops.ErrShardFailure(Hits and Pages). Returned alongside a hit/page that came from a search response with_shards.failed > 0for a non-PIT-context reason (circuit breaker, task cancelled, etc.). The cursor has advanced past the failed shard's slice, so docs from that shard at this cursor range are lost. Caller decides: tolerate (mark partial) or abort. The paginator does not retry or recreate, because PIT pins to specific contexts and a non-context shard failure is not a snapshot problem.Note: both errors are returned with a valid
Vvalue. This is a deliberate departure from "err != nil means V is zero" and is documented in the package doc. Fatal errors (context cancellation, exhausted recreate budget, transport failure that does not classify) still return a zeroV.The caller picks one of two delivery modes:
Hits). Maintain a bounded LRU set of recently-emitted_ids and skip duplicates after a recreate, surfacingErrHitOutOfOrderfor the version-changed case. Trade: may omit documents whose sort values shifted across the boundary and now fall behind the cursor.Pagescannot dedup individual hits and inherits emit-all by default; onlyErrSnapshotBreakapplies.Observability
Two layers, matching the rest of the client:
Counters and gauges via the existing
client.Metrics()snapshot. The PIT paginator increments fields on the internalopensearchtransport.metricsstruct; new fields on the publicMetricssnapshot:The "currently open PITs" gauge is
PITsOpened - PITsClosed, computed at scrape time.PITShardFailuresis keyed byreason.type(e.g.,search_context_missing_exception,circuit_breaking_exception) so callers can split PIT-related from transient. Callers wireclient.Metrics()into Prom/OTel via their own existing scraper; we do not take a dependency on either library.Per-event callback for rich context.
OnSnapshotBreak(already onPITPaginatorConfig) fires once per recreate with old/new PIT id, cursor, failed-shards detail, cause, and attempt number. This is the hook ops uses when a counter spike needs to be cross-referenced with a specific incident. Counters alone do not carry that context.Configuration
Memory footprint for the dedup window. A bounded LRU implemented as
map[string]struct{}plus a circular buffer of_idstrings (cheaper thancontainer/list, same API surface). Per-entry cost on 64-bit Go: ~16 B string header + ~20 B string data (default OpenSearch auto-generated_idis 20 chars; seeTimeBasedUUIDGenerator.java, 15-byte UUID base64-encoded without padding) + ~32 B amortized map slot + ~16 B ring slot ≈ ~75-80 B per entry. At the v5 default of32768entries, steady-state is roughly 2.4 MiB per fully-loaded paginator.Custom
_idschemes shift this:Adjust
SeenIDsWindowfor high-cardinality jobs, long custom_ids, or memory-constrained processes. A bloom filter would lower the per-entry cost (and admit false positives, which here means rare missed dedups, i.e., emitting a duplicate the caller could have skipped); deferred as an optimization until profiling justifies it.Lifecycle
The paginator creates and deletes the PIT by default.
WithExistingPIT(id)is an escape hatch for callers sharing a PIT across runs; in that mode the paginator does not delete onClose.Out of scope
ctx; timeout-driven concurrency is the caller's responsibility.Open questions
delay = jitter(min(250ms * 2^attempt, 10s)). Rationale: PIT recreate is not retrying a hot query against an overloaded cluster, it's recreating a fresh id that picks up surviving replicas, so the backoff exists to absorb classifier flakiness (e.g., a probe race between the old PIT id partially expiring) rather than to wait out a node restart. Three attempts with this curve totals at most ~14s of wall time before giving up. Tunable viaBackoff func(attempt int) time.Durationfor callers with stricter SLOs.WithExistingPIT+MaxRecreateAttempts > 0interaction. If the caller hands us a PIT id and we recreate it on failure, the new id is not the one they manage. Options: (a) refuse the combination at construction time, (b) recreate freely and surface the new id viaOnSnapshotBreakso the caller can update their bookkeeping, (c) recreate but mark the paginator detached from the original id. Leaning toward (b); flagging for review.