fix(database): make a failed database open recoverable, and report it on /readyz - #2157
Open
vchaindz wants to merge 2 commits into
Open
fix(database): make a failed database open recoverable, and report it on /readyz#2157vchaindz wants to merge 2 commits into
vchaindz wants to merge 2 commits into
Conversation
allocDB caches a placeholder &dbRef{count: 1} before openDB runs. When the
open failed, Get called Release, which only decremented the refcount and left
a dbRef{db: nil, count: 0} in dbCache for the life of the process. That single
entry poisoned every reader that consults the cache:
- GetState returned "unable to get state" forever and never reached the
m.Get fallback below it that would have retried the open;
- IsActive reported a database that never opened as active;
- GetOptionsByIndex returned a nil *Options, which lazyDB.Path() and
lazyDB.MaxResultSize() dereference;
- CloseAll needed a nil guard (bfa78b3) to avoid panicking on shutdown.
A transient 403 from S3 during the first open of two databases was therefore
terminal: the client state path (CurrentState -> lazyDB.CurrentState ->
GetState) short-circuits before m.Get, so nothing re-drove the open and no
further storage traffic was attempted for over three hours until a restart.
Release now pops the ref when the open failed and nobody picked it up, so a
dbRef with a nil db is never observable. The pop lives in Release rather than
a parallel cleanup path so that the pendingClose hand-off still runs when
Close races a failed open; without that, a parked ref is orphaned and its
database never closed.
Get also unlocks db.mtx before Release acquires m.mtx. allocDB takes m.mtx
then db.mtx and returns holding db.mtx, so the old ordering inverted the two:
a second caller inside allocDB for the same index held m.mtx and blocked on
db.mtx while the failing open held db.mtx and waited on m.mtx. Since allocDB
takes m.mtx first, that wedged Get for every database, not just the failing
one. The refcount always reaches zero on this path, so the inversion was
reached on every failed open; only a concurrent caller was needed to close the
cycle. TestDBManagerFailedOpenDoesNotDeadlock hangs without this change.
GetState now falls through to m.Get instead of the sentinel error, surfacing
the real storage error and re-driving the open, rate-limited by
openRetryInterval so a permanently broken database is not hammered. A recorded
failure outranks any state cached by a previous successful open, otherwise a
database that fails to reopen would serve a stale state and never retry.
GetOptionsByIndex collapses to dbInfo.getOptions(): both of its branches
returned the same options, and the only distinct effect of the cache branch
was the nil.
OpenDB and NewDB additionally leaked the store on the sql.NewEngine,
document.NewEngine and NewTxHolderPool error paths, where only InitIndexing
closed it. A leaked store would make the retries this change enables fail.
Fixes #2155
Signed-off-by: vchaindz <dennis@codenotary.com>
Health returns Status: true unconditionally and DatabaseHealth reports request statistics, so neither can tell an orchestrator that a database is unusable. DatabaseListV2 is not an answer either: Loaded is !IsClosed(), and closed is set only by Close() or PutClosed(), never by a failed open, so a database whose open failed still reports Loaded: true. The consequence is that when a transient 403 from S3 left two databases unopened (#2155), the pod stayed 1/1 Running and the liveness probe passed for the whole outage. It surfaced instead as a crashloop in the client services, pointing on-call at the consumers rather than at immudb. The metrics server already serves /initz, /readyz and /livez, all of them unconditional 200 stubs. /readyz now returns 503 and names each database whose most recent open attempt failed and which has not opened successfully since. /livez and /initz stay unconditional: liveness should not depend on storage reachability. A database that was never accessed has never attempted an open and is not reported, so registering databases lazily at startup does not fail readiness. The failure record is read atomically rather than under the dbInfo mutex, which is held for the entire duration of an open and so is precisely unavailable when a probe needs an answer. IsActive is corrected in passing: it only checked that the cache lookup succeeded, which was true for a ref whose open had failed. It now requires a ref that actually holds an open database. Closes #2156 Signed-off-by: vchaindz <dennis@codenotary.com>
Collaborator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2155
Closes #2156
Problem
allocDBcaches a placeholder&dbRef{count: 1}beforeopenDBruns. When the open fails,GetcallsRelease, which only decrements the refcount — adbRef{db: nil, count: 0}stays indbCachefor the life of the process. That single entry poisons every reader that consults the cache:GetStateunable to get stateforever, and never reaches them.Getfallback below it that would retry the openIsActiveGetOptionsByIndex*Options, whichlazyDB.Path()andlazyDB.MaxResultSize()dereferenceCloseAllIn the reported incident a transient S3
403during the first open of two databases was therefore terminal for 3 h 20 min, until a restart.Why nothing retried: the client state path short-circuits before
Get.m.Getis never called, soopenDBis never retried — matching the reporter's observation that no S3 traffic appeared during the outage.UseDatabasepasses too, because it checksdb.IsClosed()and a failed open never setsdbInfo.closed.Meanwhile
HealthreturnsStatus: trueunconditionally, so the pod stayed1/1 Runningand the outage surfaced as a crashloop in the client services instead.Changes
Releasepops the ref when the open failed and nobody picked it up, so adbRefwith a nildbis never observable. The pop lives inReleaserather than a parallel cleanup path so thependingClosehand-off still runs whenCloseraces a failed open — a separate function orphans the parked ref and its database is never closed.Getreleasesdb.mtxbeforeReleaseacquiresm.mtx. This fixes a lock-order inversion reachable onmaster:allocDBtakesm.mtxthendb.mtxand returns holdingdb.mtx, so the old ordering let a second caller insideallocDBfor the same index holdm.mtxand block ondb.mtxwhile the failing open helddb.mtxand waited onm.mtx. SinceallocDBtakesm.mtxfirst, that wedgedGetfor every database. The refcount always reaches zero on this path, so the inversion was reached on every failed open — only a concurrent caller was needed to close the cycle.This is not what caused #2155: a deadlocked opener holds
db.mtx, which would have blockedGetStateondbInfo.mtxand silenced the metrics goroutine, and the reporter saw its log line every minute throughout. It is a separate latent bug on the same path.GetStatefalls through tom.Getinstead of returning the sentinel, surfacing the real storage error and re-driving the open, rate-limited byopenRetryInterval. A recorded failure outranks any state cached by a previous successful open, otherwise a database that fails to reopen serves a stale state and never retries. Net effect: the once-a-minute metrics loop becomes a self-healing retry driver.GetOptionsByIndexcollapses todbInfo.getOptions()— both branches returned the same options; the only distinct effect of the cache branch was the nil.OpenDBandNewDBnow close the store on thesql.NewEngine,document.NewEngineandNewTxHolderPoolerror paths, where onlyInitIndexingdid. A leaked store would make the retries this PR enables fail on the next attempt./readyzreports real state. The metrics server already served/initz,/readyzand/livezas unconditional200stubs./readyznow returns503and names each database whose most recent open attempt failed and which has not opened successfully since./livezand/initzstay unconditional — liveness should not depend on storage reachability. A database that was never accessed has never attempted an open and is not reported, so lazy startup registration does not fail readiness. The failure record is read atomically rather than under thedbInfomutex, which is held for the whole duration of an open and so is precisely unavailable when a probe needs an answer.IsActiveis corrected in passing to require a ref that actually holds an open database.Not included
DatabaseListV2'sLoadedfield is unchanged. Making it reflect real open state alters an existing field's meaning for current clients, and distinguishing "explicitly unloaded" from "failed to open" properly wants a new field and a proto change./readyzprovides the orchestrator signal without that.Separately worth filing:
allocDBholdsm.mtxacrossdb.mtx.Lock()whileGetholdsdb.mtxacrossopenDB, so one slow open of one database stallsallocDBfor every database. Fixing that structurally (haveallocDBreturn withoutdb.mtx) makes the inversion above impossible rather than merely avoided, but it has a much larger blast radius than this fix.Tests
TestDBManagerCloseAllAfterFailedOpenis updated — it asserted the ref remains cached, encoding the bug.Added: a deadlock regression test (hangs against the old lock ordering — verified by reverting just that hunk), failed-open-then-recover through
GetState, concurrent fail-then-succeed on one index,Closeracing a failed open (guards thependingClosedrain),GetOptionsByIndexafter a failed open, theOpenFailuresaccessor, and/readyzin both states.go build ./...andgo vet ./...clean.go test -race ./pkg/database/...andgo test ./pkg/server/...pass. The first commit was verified green standing alone.