Skip to content

fix(search): repair shards after recovered faults - #1125

Open
aaaaaandrew wants to merge 1 commit into
sourcegraph:mainfrom
aaaaaandrew:fix/shard-fault-repair
Open

fix(search): repair shards after recovered faults#1125
aaaaaandrew wants to merge 1 commit into
sourcegraph:mainfrom
aaaaaandrew:fix/shard-fault-repair

Conversation

@aaaaaandrew

@aaaaaandrew aaaaaandrew commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • convert mmap access faults into recoverable panics at the per-shard Search and List boundaries
  • reopen a faulted shard asynchronously through a bounded, generation-aware repair queue
  • install the reopened shard only if the exact faulted instance is still current
  • close every partially opened or superseded replacement on panic and race paths

This addresses the shard-repair portion of #1106.

Behavior

The query that encountered the fault remains incomplete and retains its crash count. It is not retried or rewritten as successful. A later query can use the reopened shard.

Only runtime memory faults carrying Addr() uintptr schedule repair. Existing corruption, bounds, and nil-pointer panics remain contained but do not repeatedly reopen unchanged corrupt bytes.

Repairs reuse loadShard and the existing replacement path. At most four reopen operations run concurrently per sharded searcher. Single-flight state is keyed by the exact loaded shard instance, so a watcher-installed generation at the same path can schedule its own repair while an older generation is still finishing.

Repair state does not feed Ready(): a failed attempt releases its slot so later traffic can retry, rather than withdrawing the traffic needed to trigger that retry. Errors and recovered reload faults remain logged.

Controlled before/after

The Linux regression builds a real 20 MB, 700-document shard, loads it through shardedSearcher, truncates the mapped backing file, publishes the original bytes at a new inode, and searches again without running DirectoryWatcher.

  • unmodified upstream: exits on SIGBUS with fatal error: fault
  • SetPanicOnFault containment only, without repair: survives but still returns FileCount=0, Crashes=1 after 15 seconds
  • this PR: reports the faulted query as incomplete, reopens the replacement, and restores FileCount=700, Crashes=0 in the next bounded repair cycle

This validates fault containment and actual loadShard/conditional-replacement healing independently. It does not claim to fix the publication-lifetime root cause in #1109.

Test plan

  • go test ./...
  • go test -race ./search
  • go vet ./...
  • Linux go test ./search, including real inaccessible-mapping coverage and the real-shard no-watcher repair test
  • repeated generation-race, ownership-transfer, nil-pointer classification, and failed-repair retry tests

@keegancsmith keegancsmith left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ok I haven't deeply reviewed this but have some feedback I'd be interested in before deeply reviewing. Also sharing this bit of review an agent did for me which seems legit.

Medium: recovered reload panics leak mappings
The outer recovery boundary in shard_repair.go:103-112 cannot close a replacement opened inside shards.go:1301-1323. A fault during NewSearcher or mkRankedShard.List leaks the mmap; repeated retries leak additional mappings.
Fix: make ownership transfer panic-safe with a deferred close until installation succeeds.

Comment thread search/shards.go Outdated
metricListShardRunning.Dec()
if r := recover(); r != nil {
logShardCrash("list", s, q, r, debug.Stack())
ss.shardRepairs.schedule(s)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we also panic for corrupt shards, not just due to memory faults. Reloading the whole shard doesn't really help in this case.

I think you can inspect r to check if it implements interface { Addr() uintptr } then only attempt repair if its a mmap fault.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Repair is now limited to recovered values that implement both runtime.Error and Addr() uintptr. Corrupt-shard panics and a real nil-pointer panic are still contained, but neither schedules a reopen; both cases have regression tests.

Comment thread search/shard_repair.go
}
}

func (q *shardRepairQueue) schedule(searcher zoekt.Searcher) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A bit of a race with the watcher in this implementation due to solely deduplicating by path. eg if shard A is repairing, the watcher installs B, and B faults, schedule(B) is rejected because A still owns that path’s inFlight entry. When A finishes, the queue reports ready even though B remains faulted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Single-flight state is now keyed by the exact loaded Searcher instance; the path is only the reload target. The regression test blocks A’s repair, installs B at the same path, and verifies B can schedule and finish before A is released.

Comment thread search/shard_repair.go
Comment on lines +124 to +137
q.mu.Lock()
currentKey, stillRegistered := q.entries[request.faulted]
if resolved || !stillRegistered || currentKey != request.key {
delete(q.unresolved, request.key)
} else {
if q.unresolved == nil {
q.unresolved = make(map[string]struct{})
}
q.unresolved[request.key] = struct{}{}
}
delete(q.inFlight, request.key)
q.running--
q.startPendingLocked()
q.mu.Unlock()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It isn't clear to me we will retry a shard to remark the shard as ready. IE if we are marked unready => no traffic => never gets a retry. Althought I might be misunderstanding this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You were right. Repair state no longer feeds Ready(). A failed reopen releases its single-flight slot and stays observable in the error log; later traffic can retry it instead of readiness withdrawing the traffic needed to trigger that retry.

@aaaaaandrew
aaaaaandrew force-pushed the fix/shard-fault-repair branch from 610578a to ca174e2 Compare August 12, 2026 18:11
@aaaaaandrew

Copy link
Copy Markdown
Contributor Author

Also fixed the mapping leak from the review summary. loadShard now retains ownership of the IndexFile until NewSearcher returns successfully, and the repair path retains ownership of the reopened searcher until installation completes. Panics in either NewSearcher or mkRankedShard.List, plus the ordinary superseded-install race, now close the replacement and have direct tests.

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