STAC-25639: fix false-success on elasticsearch restore, plus port-forward retry - #36
STAC-25639: fix false-success on elasticsearch restore, plus port-forward retry#36viliakov wants to merge 7 commits into
Conversation
…ry stages Treating "no active recovery" as done reported success 11ms after _restore was accepted, before Elasticsearch had registered any shard recovery, so the restore was finalized and the workloads scaled back up mid-restore.
There was a problem hiding this comment.
Direction is right and the tests cover the case that broke. Two things before merge.
1. Throttled allocation leaves the same false-success window. _cat/recovery never lists shards still queued for allocation, and primaries restored from a snapshot are throttled per node by cluster.routing.allocation.node_initial_primaries_recoveries (default 4). With sts*,.ds-sts_k8s_logs* well past 4 primaries per node there are instants where every registered snapshot recovery is done while further shards are still unassigned — internal/clients/elasticsearch/client.go:438-456 then returns SUCCESS and finalizes mid-restore. It is a race rather than a certainty (a reroute follows each completed recovery, so the gaps are short), but the poll runs every 10s across dozens of gaps.
The fallback you offered closes it and does not need local pattern matching — pass the pattern to ES: GET <indicesPattern>/_cluster/health, complete only when initializing_shards and unassigned_shards are 0. Queued shards are visible there and structurally invisible to _cat/recovery.
2. The trade-off you flagged is an infinite hang, not a misleading status. WaitForAPIRestore (internal/orchestration/restore/apirestore.go:29) has no deadline, and both foreground restore and check-and-finalize --wait route through it. Any permanent IN_PROGRESS — restore failed and indices dropped, or shards relocated so the recoveries become peer recoveries — now polls forever instead of terminating with a wrong success. In verify-backup that is the 12h job timeout with e2es/receiver-* still scaled down and the restore lock held. Related: NOT_FOUND in cmd/elasticsearch/check_and_finalize.go:78 is unreachable by construction now, so recovering by re-running check-and-finalize only works while the done entries persist. The health check in (1) fixes both cases; if you keep the fix minimal instead, a no-progress deadline belongs in this PR since this is what makes the state reachable.
Nits, non-blocking:
- The retry now covers deterministic failures too (
failed to get service,no pods found,no running pods); combined with the 60s readiness timeout, a hard-down service takes ~3min to fail. internal/orchestration/portforward/portforward.go:80— the tailreturnis unreachable formaxAttempts >= 1; alastErrvariable is the usual shape.- The three deleted
SetupPortForwardtests were the only coverage of the k8s service/pod error paths. - Both new comments in
GetRestoreStatusexplain real non-obvious why, but each is longer than the code it guards.
|
Follow-up on the completion signal, raised offline: check that the snapshot's indices are all present with active primaries, instead of deriving completion from recoveries. The expected list does not need storing — Local pattern matching is already solved here too: Predicate:
Do not require green — yellow is the steady state whenever replicas cannot be allocated (single-node nightly ES), so a green gate would never finish. Require primaries active: This subsumes both points in my review. Queued-but-unallocated shards are visible as unassigned primaries and structurally invisible to Worth confirming two things first: that |
…lled restores Shard recovery is throttled per node, so queued shards are unassigned and absent from _cat/recovery: at a batch boundary every registered recovery reads done while shards remain, which still reported success early. Completion now requires each restored index to be present with all primaries active, and a no-progress deadline fails a stuck restore instead of holding the lock until the caller times out.
|
Both blocking points accepted and fixed in f21a3ac, which replaces the recovery-stage approach in c48510e. Throttled allocation — you're right, and thanks. Queued shards are unassigned and absent from Unbounded wait — agreed, and I understated it by calling it a misleading status. Added a 30-minute no-progress deadline; a stalled restore now fails instead of holding the lock with workloads down until the caller's timeout. Completion is now: every expected index present with One correction, on the Every index is Your two open questions, both confirmed against the live cluster:
Also worth recording: One nit is incorrect. The deleted The other two port-forward nits (retrying deterministic errors, the unreachable tail
|
…earch restore Status polling now spans the whole restore, so the tunnel dying mid-wait aborted a restore that was still running server-side: an end-to-end run lost its ES pod to infra activity and failed after nine successful polls. Transient status errors are now tolerated and the port-forward rebuilt, the unreachable NOT_FOUND branch is gone, and the interrupt hint includes the --wait it needs to finalize.
|
Validated end to end against the nightly instance, which found one more problem. The completion fix works. A real restore of 109 indices, with The counter matched an independent probe of the cluster at the same moments, and the restore finished at What it exposed. The ES pod was restarted by infra activity mid-wait and the restore died on the next poll: A single transient failure ended a restore that was still running server-side. This is the mid-flight tunnel death that #35 does not cover, since it only retries setup — and it never mattered before, because the buggy check finished in 11ms so there was no exposure window. Making ES actually wait created it. Status errors are now tolerated (5 consecutive) and the port-forward is rebuilt on failure; the client is bound to a fixed local port, so a dead tunnel needs a new forward and a new client. ClickHouse has the same gap on its own poll loop — not touched here. On the throttling window — your mechanism is confirmed, the timing is not. Directly observed mid-restore: 12 concurrent recoveries — 4 per node across 3 nodes — while ~520 shards sat queued and invisible to One data point that does settle the green-vs-primaries question. At the moment all primaries were active, 257 shards were still unassigned (replicas). Gating on Also in this commit: the unreachable Full suite and |
… index Counting fully-restored indices is too coarse for stall detection: a large multi-shard index restores for a long time without completing, so a healthy restore could look stalled. Progress is now active primary shards, and the deadline is generous because a spurious failure aborts a working restore while waiting too long only delays a failure.
…urable Restore durations vary enough per environment that a compiled-in value cannot fit all of them. Adds --no-progress-timeout to restore and check-and-finalize, defaulting to the previous 2h, where 0 waits indefinitely.
|
Re-reviewed through 1. A stall is reported as 2. There is no way to release the restore lock if the snapshot cannot be read. Neither is a blocker for the bug this PR fixes. Also two optional notes: |
…iled A stall only establishes that no local progress was seen, and verify-backup's retry deletes every STS index first.
|
All four addressed in 1. Stall no longer reports as Implementation note: I put it through the error channel rather than adding a 2. Escape hatch added. 3. 4. One-shot path surfaces errors. Also verified on the built binary: the mutual exclusion fires, a missing Description rewritten to describe the final state rather than the sequence of revisions. |
elasticsearch restorereported success while the restore had barely started, finalized, released the lock and scalede2es/receiver-*back up mid-restore. In run 33158861069 that verdict came 11ms after_restorewas accepted, andrun-validationfailed afterwards.The cause was structural. Completion was inferred from the absence of active shard recoveries, which cannot separate not started, finished and failed. A longer poll interval would not have helped: the wait loop was never entered.
What changed
Completion is now positive — every index the snapshot restores must be present with all its primary shards active. The expected list is the snapshot's indices filtered by the configured STS prefixes, re-read via
GetSnapshotsocheck-and-finalizeworks from a snapshot name alone.Replicas are deliberately excluded. Requiring
unassigned_shards == 0would never finish on a cluster with fewer nodes than configured replicas, and even on the healthy 3-node nightly cluster 257 replica shards were still unassigned at the moment every primary was active — so it would also delay completion well past the point the data is queryable._cat/recoverycannot express this: shards queued behind the per-node recovery throttle are unassigned and absent from its output. Mid-restore it listed 14 shards while_cluster/healthreported 522 unassigned.GetRestoreStatusandRecoveryInfoare replaced byGetIndicesHealth.The wait is now bounded and survives a dropped tunnel. Polling spans the whole restore, so it must tolerate losing its port-forward — an end-to-end run lost its ES pod to infra activity and would otherwise have failed after nine good polls. Transient status errors are tolerated and the port-forward rebuilt; a one-shot check without
--waitstill surfaces them rather than reading a dead tunnel as a running restore.A no-progress deadline (
--no-progress-timeout, default 2h,0waits indefinitely) stops a stuck restore from polling until the caller's timeout with the lock held. It bounds inactivity, not total restore time, and progress is counted per primary shard so a slow multi-shard index is not mistaken for a stall.A stall is reported as stalled, not failed. It only establishes that no progress was seen locally — the restore may still be running server-side. That distinction matters because verify-backup's retry deletes every STS index before restoring, so a "failed" verdict on a merely slow restore destroys work in flight.
check-and-finalize --finalize-onlyscales the deployments back up and releases the lock without reading status, for when the snapshot is gone or Elasticsearch is unreachable. It needs Kubernetes only, and is the escape hatch that replaces the unreachableNOT_FOUNDbranch.Also included: the port-forward setup retry cherry-picked unchanged from
stac-24630(#35).Reviewer notes
cmd/elasticsearchbecause it needsfilterSTSIndicesand the configured prefixes, which the client layer cannot import;validateSnapshotStatealready sits there.reconnectingHealthClientmust never close a port-forward it did not open, or the caller's deferredclosedouble-closes a channel. Pinned by test.return) sit inside the cherry-picked commit, left byte-identical to what is merged onstac-24630.Validation
go build ./...,go vet ./...,go test ./...,golangci-lint run --config=.golangci.yml ./...(v2.11.3, as CI pins) — all clean.Exercised end to end against the nightly instance: a 109-index restore tracked from 0 to complete, then
check-and-finalize --waitfinalized it, leaving all three deployments at1/1with norestore-in-progressorpre-restore-replicasannotations. Response shapes and hidden.ds-index visibility were confirmed against that cluster.https://stackstate.atlassian.net/browse/STAC-25639