Lite reopens its database after a fatal DuckDB error and repairs its indexes at every open - #4930
Merged
Merged
Conversation
…indexes at every open A fatal DuckDB error invalidates the whole database: every later statement fails until every connection closes and the file opens again, so collection and alerting stopped for every server until Lite was restarted. Lite now reopens the same file under the write lock (3 attempts with back-off), says so in the status bar and on Collection Health, and logs the fatal error and the reopen. At every open, an explicit CHECKPOINT and then a rebuild of each explicit index (each DROP and CREATE committed on its own) keep rows that WAL replay restored in their indexes (duckdb#26106), so a later delete over them no longer fails with a FATAL error.
The index repair at the open can drop an index that then cannot be built again, for example one too large to build within the memory limit. The schema's CREATE INDEX IF NOT EXISTS statements then met the same failure and stopped the start, and every start and reopen attempt after it. On an existing file, a declared index that cannot be created now logs one ERROR and the start carries on, so the next start tries again (the same pattern as the missing-column heal). A fresh file still fails, because there a declared index that cannot be built is a bug. Also: the comment no longer names a server removal as a delete over indexed rows (it deletes none).
TsqlConventionGuardTests.TheMemberScan_ReadsEveryDeclarationWhole reads a multi-line expression-bodied member short of its end. The same expression in a block body reads whole, so no entry is added to KnownTruncatedRanges.
…es collection while it is down A reopen attempt takes the collection gate the size-triggered reset uses, so it runs after every registered collection has ended and none starts until it is done. Collector runs, the Query Store backfill and the loop's housekeeping skip while the database is down, a per-database loop ends at the first fatal error, and the backfill's connections take the database lock. A fatal error in the open's CHECKPOINT, index rebuild or declared index statements stops the open with an error that names the step. One run reopens at most five times. A dispose during an attempt closes the sentinel the attempt opened. An existing file is told by whether it was there before the open.
…one line says so each way
…d and resumes lines are written in order Past the collection gate's 3-minute drain timeout, the wait for running collections logs a Warning with their count, and again after each further 3 minutes. It keeps waiting. The health read, the compare and the paused or resumes line in LocalDatabaseIsDown are one step under a lock, so a caller holding a read from before a reopen cannot log the change backwards.
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.
What users see
A Lite install that monitored 9 servers hit a corrupted-index error in its local DuckDB database, less than a second after two out-of-memory errors. That error is FATAL in DuckDB: it invalidates the whole database, and every later statement on every connection fails with "database has been invalidated because of a previous fatal error". Collection and alerting stopped for all 9 servers, and nothing on screen said so. Only a restart of Lite brought them back. The out-of-memory errors are fixed in #4924.
With this change:
Cause
There are two parts.
We do not know what corrupted the index in the incident. Part 1 makes Lite recover from any fatal error. Part 2 removes one known cause.
Changes
Reopen after a fatal error
DuckDbInitializer.Recovery.cs(new):ReportFailure(exception)checksDuckDBException.ErrorType == Fatal, through inner and aggregate exceptions. It checks the type, never the message text. Out-of-memory and every other error are ignored.InitializeAsync, the same steps as a start. That closes the sentinel, opens the same file, runs the open steps, and opens a new sentinel. It never takes the reset path, which deletes files.MaxReopenCycles). A fatal error after the fifth sets the state to Failed instead of starting a sixth. So a database that keeps failing stops, instead of reopening forever.collection_logwrite that ends every collector run. It sees a fatal error even when the collector absorbed its own failed write.MainWindow) and the Collection Health tab (ServerTab) read the state from memory, never from the database.Collection pauses while the database is down
RemoteCollectorService.LocalDatabaseIsDown()reads the state from memory. While the state is Reopening or Failed:collection_logrow, so it stays due and runs in the first sweep after the reopen. Every way into a collector run reaches this check.RunDueCollectorsAsync,RunAllCollectorsForServerAsync) returns right after it registers with the collection gate. A sweep that was already running skips its closingCHECKPOINT.An Azure SQL Database collector reads each database in a loop. That loop now ends at the first fatal error, instead of reading every remaining database and failing each write. The enumerated per-item loop does the same.
Every connection to the file
The first version of this change said that the reopen's write lock waits until every caller closes its connection. That was wrong. A collector run opens its connection to the file without the lock, because it keeps the connection open while it reads from the monitored server. So the write lock did not wait for it, and an attempt opened the file while a collector still held the invalidated instance. DuckDB.NET then hands the attempt that same instance, so the attempt fails and spends one of its 3 tries. P1 shows this on the first version.
A reopen works only if no connection to the invalidated instance is open when the attempt opens the file. Three rules now give that:
CollectionResetGate, [BUG] Lite - Table ".main.*" could not be found #2594). These are the scheduled sweep, the sweep when a server tab opens, and the tab's refresh. An attempt takes the gate first. The gate waits until every registered collection ends, which is the point right after a sweep's last collector. It also keeps a new one from starting until the attempt ends. The size-triggered archive and reset already takes the same gate before it deletes the file.InitializeAsynctakes the write lock. That waits for those connections to close. It keeps new ones out until the open is done, the index rebuild included.With no collection running, the gate is free at once, so the attempt runs right after its wait. That covers no servers, every server offline, and collection paused. No collection round is needed to start the attempt.
A search of every place that opens a connection to the file found these:
RunCollectorDefinitionAsync: the single read, the enumerated per-item loop and the Azure SQL Database per-database loop (3 opens)SaveCollectorStateAsyncalready takes. The backfill also skips while the database is down.DuckDbInitializer: the sentinel, the open steps and the archive viewsInitializeAsynccloses the sentinel itself.RemoteCollectorService: thecollection_logwrites, collector state, the closingCHECKPOINTand its other opens (9 in all)FindingStoreLocalDataService(every grid and chart),DeltaCalculatorandArchiveServiceDataImportServicemonitor.duckdbin the folder the user imports from, never Lite's own file.ParquetCompactionA connection that leaks anyway keeps the invalidated instance alive. Then each attempt fails, and after 3 the state is Failed. A pin covers that case.
Index repair at every open (duckdb#26106)
DuckDbInitializer.ReplayedIndexes.cs(new) runs inInitializeCoreAsyncright after the open. It runs before the migrations, before any DELETE, and before the schema's index statements.CHECKPOINT. This is the upstream workaround: a checkpoint right after the replay writes the replayed rows' index entries correctly. If it fails with an ordinary error, Lite logs an ERROR, does not rebuild, and opens.duckdb_indexes()is dropped and created again from its ownsql. A rebuilt index holds every row of its table, so this repairs an index that an earlier open already damaged. If a step fails with an ordinary error, Lite logs an ERROR and opens.A FATAL error in either step is different. It invalidates the database, so the open cannot carry on. The step throws an error that names it, for example "The CHECKPOINT Lite runs right after opening ... failed with a fatal error, so the database cannot be used". A start then fails with that message instead of the next statement's "database has been invalidated", and a reopen counts it as a failed attempt. The first version of this change logged a fatal
CHECKPOINTand carried on.Both steps run at every open, a clean one included. Damage from an earlier session cannot be told from a healthy index without reading it.
The cost has a bound. When the file reaches 512 MB, the collection loop archives every table to parquet and resets the file. So the file stays near that size or below it. On the 402 MB copy of the incident's store below, the rebuild took 622 to 637 ms for 45 indexes. So the rebuild stays at about a second at most.
Each DROP and each CREATE commits on its own. A transaction that drops and re-creates an index loaded from the file fails at COMMIT when the commit is written to the WAL. DuckDB.NET dies with a native access violation that no catch can stop. Lite's
checkpoint_threshold=1GBsends every such commit to the WAL, so each DROP and CREATE commits on its own. The first version of this change used one transaction, and its own tests crashed the test host on a plain reopen. A separate probe, run once per case, gave these results:CHECKPOINTfirstCHECKPOINTCHECKPOINT, then the rebuildCHECKPOINTCHECKPOINTThe Python client of DuckDB 1.5.5 fails at the same COMMIT with an internal error. So the failure is in DuckDB, not in the .NET binding.
With separate commits, an index is missing between its DROP and its CREATE. Nothing else uses the file meanwhile. The open holds the write lock, which every connection outside a collection takes. At startup no collection is running yet, and a reopen also holds the collection gate. If a CREATE fails, that index stays dropped:
CREATE INDEX IF NOT EXISTSstatements run later in the same open, so they try again for any index that Lite declares.CREATE INDEXstatement of the dropped index, so someone can restore it by hand.The incident's store has 45 explicit indexes, and Lite declares all 45: 40 generated for collectors, 2 hand-written, and 3 for analysis. All migration
DROP INDEXstatements useIF EXISTS, so a missing index cannot break a migration.A declared index that cannot be created
Before this change, the schema's index statements always found their index in place and did nothing. Now an index that the repair dropped and could not create again reaches them. If the cause lasts, for example an index too large to build within the 1 GB memory limit, the schema statement fails the same way. It used to throw, so Lite could not start, and every later start and every reopen attempt failed at the same statement.
Both index loops, the schema's and the analysis schema's, now use the pattern of the #4727 missing-column heal. On an existing file, a declared index that cannot be created logs one ERROR with its statement, and the start carries on without it. The next start tries again. A fresh file still throws, because a declared index that cannot be built on an empty table is a bug for the tests to catch. A FATAL error still throws too, because the database is invalidated.
An existing file means that the file was there before the open. Lite checks that with
File.Existsbefore it opens the file. The first version used the schema version instead. But a failed read of the version reads as 0. So an existing file whose version read failed was treated as a fresh file, and its first index that failed to build stopped the start.Remove both steps when Lite ships a DuckDB release that fixes duckdb#26106 for both the shutdown checkpoint and the automatic checkpoint. The 2.0 nightly fixes only the shutdown path. The code comment says the same.
Other
Schema.cs: two doc comments gave stale counts ("36 collector tables", "34 generated collector indexes"). The catalog now has 42 collectors and 40 generated indexes. The comments now say "one per catalog collector" so that they cannot go out of date again. No code changed.LocalDatabaseHealth.LocalTimehas a block body.TsqlConventionGuardTests.TheMemberScan_ReadsEveryDeclarationWholereads a multi-line expression-bodied member short of its end, and it failed on the first version of this method. With a block body the scan reads the method whole, soKnownTruncatedRangesdoes not change.On a copy of the incident's store
The copy was 402 MB with a 1.5 MB WAL, 62 tables, about 996,000 rows and 45 explicit indexes. It used Lite's
memory_limit=1GBandcheckpoint_threshold=1GBand the separate commits. The copies were deleted afterwards. These are timings and counts only.The timing run opened one copy twice:
CHECKPOINTThe peak working set of the process over both opens was 352 MB. So the repair adds about 0.6 s to a normal start and about 1.9 s to the first start after a crash.
Full deletes of every indexed table, each case on a fresh copy:
CHECKPOINTonly, no rebuildOn this store, the
CHECKPOINTalone was enough. The rebuild is for a store that an earlier build already opened and closed after a crash.Changes after the second check
A second check of this change found nothing that blocks a merge, and two small gaps in what the log says. Both are closed here. Each has a pin, and a mutation that breaks the pin.
ReopenGateWarningInterval, which a test can shorten. The gate'sDrainTimeoutis now internal, so both use one value.LocalDatabaseIsDownread the state, then swapped it into the last logged state. In a race, one caller read Reopening just before a reopen ended. Another caller then logged "resumes". The first caller swapped Reopening back in and logged "paused", and the next caller logged "resumes" a second time. P6 shows this without the lock. Now the read, the compare and the line run under one lock. A re-check after the swap is not enough. With it, the stale caller still puts Reopening back, only without a line, and the next caller still logs "resumes" again.The status text and the CHANGELOG entry do not change. Only log lines do.
Pins
LocalDatabaseReopenTestsforces a real fatal error. It sets DuckDB'sdebug_checkpoint_abort, and then aCHECKPOINTfails with FATAL and invalidates the instance.AFatalErrorInACollectorRun_ReopensTheDatabase_AndTheNextRunWritescollection_logwrite. The state is Healthy with a reopen time.debug_checkpoint_abortreadsNONE, so the instance is new. The next run writes itscollection_logrow, and a table committed before the error is still there.WhenEveryReopenFails_LiteStopsAfterThreeAttempts_AndSaysCollectionIsStoppedAnErrorThatIsNotFatal_StartsNoReopen_AndAWrappedFatalErrorIsStillSeenTheStatusLine_SaysWhatHappenedAndWhenTheSentinelProbe_StartsTheReopen_WhenNoCollectorReportsTheErrorAFatalErrorWhileACollectorReads_TheReopenWaitsForThatRun_ThenSucceedsTheQueryStoreBackfill_SkipsWhileTheDatabaseIsDown_AndSaysSoOnceAFatalErrorDuringAPerDatabaseRun_EndsTheLoop_SoNoLaterDatabaseIsReadACollectorRun_SkipsWhileTheDatabaseIsDown_AndOneLineSaysSoEachWaycollection_logwrite, and exactly one line says that collection is paused. After the reopen the next run records its result and writes its row, and exactly one line says that collection resumes.WithNoCollectionRunning_TheReopenStillRuns(3 cases)AfterFiveReopensInOneRun_TheNextFatalError_LeavesTheDatabaseFailedADisposeWhileAnAttemptRuns_LeavesNoSentinelOpenAReopenThatWaitsLongForACollection_WarnsWithTheRunningCount_AndKeepsWaitingACallerHoldingAReadFromBeforeTheReopen_CannotLogItBackwards_SoResumesIsLoggedOnceReplayedIndexRebuildTestsmakes a real unclean close. The rows are committed to the WAL, and the last connection closes withdisable_checkpoint_on_shutdown.RowsReplayedFromTheWal_StayInTheirIndex_ThroughLitesOpenAndAShutdownAnIndexAnEarlierShutdownDamaged_IsRepairedWhenLiteOpensTheRebuild_RunsAtEveryOpen_AndLeavesEveryDefinitionAsItWasTheCheckpoint_GatesTheRebuild_AndNoIndexIsDroppedAndCreatedInOneTransactionCHECKPOINTcomes before any DROP, and aCHECKPOINTthat fails with an ordinary error returns first. The rebuild never opens a transaction. The call sits inInitializeCoreAsyncbefore the migrations and before the schema's index statements.ADeclaredIndexThatCannotBeBuilt_IsLogged_AndTheOpenCarriesOnAnExistingFileWhoseSchemaVersionCannotBeRead_StillOpens_PastAnIndexThatCannotBeBuiltschema_versiontable is gone, so its version reads as 0. The open still completes, with exactly one ERROR naming that index, and 500 new rows land after it.AFatalCheckpointAtTheOpen_StopsTheOpen_WithAnErrorThatNamesTheCheckpointCHECKPOINTright after the open fails with FATAL. The open throws, its message names theCHECKPOINT, and the error is still seen as fatal.Red proofs
On the base commit, with only
ReplayedIndexRebuildTests.cscopied in, I1 to I4 fail. I1 and I2 fail with "FATAL Error: Invalid Input Error: Failed to delete all rows from index. Only deleted 0 out of 500 rows." I3 finds no rebuild line, and I4 finds no repair file. I5 came later, so mutations prove it.The pins this update adds were run against the production code of the first version, with this update's tests copied in. P1 fails because all 3 attempts are spent within the first second, while the collector still holds its connection. P3 fails because the loop reads all 3 databases. P2 fails because the backfill reads the file and logs failed reads of its collector state and its candidate databases. H5 fails because the start stops at "Invalid type for index key".
F1 passes there, as expected: with no collection running, the first version had nothing to wait for. R2 and R4 fail there only on the new status text. H2, H3 and H4 need test hooks that this update adds, and P4 came later, so mutations prove them.
Each mutation below was built and run against the pins it must break, on this update's code. Then the file was restored. Every mutation broke the pins it must break.
LocalDatabaseReopenTestsbut R4collection_logwriteCHECKPOINTis removedCHECKPOINTat the open is logged and the open carries onTests
LocalDatabaseReopenTests16, counting F1's 3 cases, andReplayedIndexRebuildTests7.Not pinned: the enumerated per-item loop's rethrow of a fatal error. No test hook drives that loop without a SQL Server. It follows the same rule as the per-database loop, which P3 pins.
Noticed, not changed:
Schema.CreateServerTagMapIndexis defined but never run, soidx_server_tag_map_tagis never created.CHANGELOG
The changelog line is written from this entry at release, so
CHANGELOG.mdis not edited here.SECTION: Fixed
ENTRY: After a crash or forced close, Lite could later stop collecting and alerting for every server until it was restarted; it now repairs its indexes at start, and after a fatal error it pauses collection while it reopens its database, then carries on ([#4930], [#4929]).
REF: [#4930]: #4930
REF: [#4929]: #4929