-
Notifications
You must be signed in to change notification settings - Fork 97
Fix two aged-database migration bugs behind #2748's Lite startup failure #2750
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| using System; | ||
| using System.IO; | ||
| using System.Threading.Tasks; | ||
| using DuckDB.NET.Data; | ||
| using PerformanceMonitorLite.Database; | ||
| using Xunit; | ||
|
|
||
| namespace PerformanceMonitorLite.Tests; | ||
|
|
||
| /// <summary> | ||
| /// #2748: a real user's database, aged past v47, failed both the v48 and v53 migrations on upgrade | ||
| /// to v3.6.0.0. Both errors were logged as "non-fatal" and the migration chain nominally completed to | ||
| /// v56, but the app then failed to start — the two swallowed failures left the database missing | ||
| /// state later code assumes is there. These tests seed the exact aged-database preconditions that | ||
| /// triggered each failure and assert the upgrade both succeeds AND leaves the database actually | ||
| /// correct, not just quietly incomplete. | ||
| /// </summary> | ||
| public class AgedDatabaseMigrationTests : IDisposable | ||
| { | ||
| private readonly string _tempDir; | ||
| private readonly string _dbPath; | ||
|
|
||
| public AgedDatabaseMigrationTests() | ||
| { | ||
| _tempDir = Path.Combine(Path.GetTempPath(), "LiteTests_" + Guid.NewGuid().ToString("N")[..8]); | ||
| Directory.CreateDirectory(_tempDir); | ||
| _dbPath = Path.Combine(_tempDir, "test.duckdb"); | ||
| } | ||
|
|
||
| public void Dispose() | ||
| { | ||
| try | ||
| { | ||
| if (Directory.Exists(_tempDir)) | ||
| Directory.Delete(_tempDir, recursive: true); | ||
| } | ||
| catch | ||
| { | ||
| /* Best-effort cleanup */ | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// v48 drops NOT NULL from three server_properties columns. On any database that has completed a | ||
| /// prior startup, Schema.GetAllIndexStatements() already created idx_server_properties_time — a | ||
| /// real, persisted index on (server_id, collection_time) — and DuckDB's ALTER COLUMN dependency | ||
| /// check refuses to touch a table with ANY index on it, even one naming none of the altered columns | ||
| /// (confirmed empirically: a plain SELECT * archive view does NOT trigger this, only the index | ||
| /// does). Seeds that exact precondition and asserts the upgrade both completes AND the column is | ||
| /// actually nullable afterward, not merely that it didn't throw. | ||
| /// </summary> | ||
| [Fact] | ||
| public async Task UpgradeFromV47_DropsServerPropertiesNotNull_EvenWithAPreExistingIndex() | ||
| { | ||
| using (var seed = new DuckDBConnection($"Data Source={_dbPath}")) | ||
| { | ||
| await seed.OpenAsync(); | ||
| await ExecAsync(seed, "CREATE TABLE schema_version (version INTEGER NOT NULL)"); | ||
| await ExecAsync(seed, "INSERT INTO schema_version VALUES (47)"); | ||
| await ExecAsync(seed, @"CREATE TABLE server_properties ( | ||
| server_id INTEGER NOT NULL, | ||
| collection_time TIMESTAMP NOT NULL, | ||
| cpu_count INTEGER NOT NULL, | ||
| hyperthread_ratio INTEGER NOT NULL, | ||
| physical_memory_mb BIGINT NOT NULL | ||
| )"); | ||
| await ExecAsync(seed, "INSERT INTO server_properties VALUES (1, current_timestamp, 4, 1, 16384)"); | ||
| /* The real dependent object: DuckDbSchemaGenerator.CreateIndex's default case for any | ||
| collector table, including server_properties, is exactly this index/column shape. */ | ||
| await ExecAsync(seed, "CREATE INDEX idx_server_properties_time ON server_properties(server_id, collection_time)"); | ||
| } | ||
|
|
||
| var initializer = new DuckDbInitializer(_dbPath); | ||
| await initializer.InitializeAsync(); | ||
|
|
||
| using var verify = new DuckDBConnection($"Data Source={_dbPath}"); | ||
| await verify.OpenAsync(); | ||
|
|
||
| /* The real assertion: a permission-free collector row (NULL hardware columns) must actually be | ||
| insertable now. Before the fix, the dependency error silently left the NOT NULL constraint in | ||
| place, so this insert would throw — the exact failure mode #2748's v48 fix exists to prevent. */ | ||
| await ExecAsync(verify, "INSERT INTO server_properties VALUES (2, current_timestamp, NULL, NULL, NULL)"); | ||
|
|
||
| using var countCmd = verify.CreateCommand(); | ||
| countCmd.CommandText = "SELECT COUNT(*) FROM server_properties WHERE server_id = 2 AND cpu_count IS NULL"; | ||
| Assert.Equal(1L, Convert.ToInt64(await countCmd.ExecuteScalarAsync())); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// v53 adds two columns to config_database_state_expected via ALTER TABLE. That table was never | ||
| /// given its own numbered migration — it only exists because Schema.GetAllTableStatements() | ||
| /// unconditionally creates it, which does not run until AFTER migrations. A database old enough to | ||
| /// predate the table (introduced by #2166/#2203, well after v47) hits the ALTER before the table | ||
| /// exists at all. Seeds a v47 database with NO config_database_state_expected table, and asserts | ||
| /// the upgrade both completes AND the table exists afterward with both new columns present and | ||
| /// actually writable. | ||
| /// </summary> | ||
| [Fact] | ||
| public async Task UpgradeFromV47_CreatesConfigDatabaseStateExpected_WhenItPredatesTheTable() | ||
| { | ||
| using (var seed = new DuckDBConnection($"Data Source={_dbPath}")) | ||
| { | ||
| await seed.OpenAsync(); | ||
| await ExecAsync(seed, "CREATE TABLE schema_version (version INTEGER NOT NULL)"); | ||
| await ExecAsync(seed, "INSERT INTO schema_version VALUES (47)"); | ||
| /* Deliberately absent: config_database_state_expected. #2748's real-world database was old | ||
| enough that this table had never been created — that is the entire bug. */ | ||
| } | ||
|
|
||
| var initializer = new DuckDbInitializer(_dbPath); | ||
| await initializer.InitializeAsync(); | ||
|
|
||
| using var verify = new DuckDBConnection($"Data Source={_dbPath}"); | ||
| await verify.OpenAsync(); | ||
|
|
||
| /* The real assertion: the columns v53 exists to add must actually be writable afterward — this | ||
| is what DuckDbAlertHistoryStore's UPDATE (the database-state alert's edge-trigger memory) | ||
| depends on, and what #2748's user's app crashed trying to do. */ | ||
| await ExecAsync(verify, | ||
| "INSERT INTO config_database_state_expected (server_id, database_name, expected_state, last_alerted_state, last_alerted_at) " + | ||
| "VALUES (1, 'TestDb', 'ONLINE', 'ONLINE', current_timestamp)"); | ||
|
|
||
| using var countCmd = verify.CreateCommand(); | ||
| countCmd.CommandText = "SELECT COUNT(*) FROM config_database_state_expected WHERE server_id = 1 AND database_name = 'TestDb'"; | ||
| Assert.Equal(1L, Convert.ToInt64(await countCmd.ExecuteScalarAsync())); | ||
| } | ||
|
|
||
| private static async Task ExecAsync(DuckDBConnection connection, string sql) | ||
| { | ||
| using var cmd = connection.CreateCommand(); | ||
| cmd.CommandText = sql; | ||
| await cmd.ExecuteNonQueryAsync(); | ||
| } | ||
| } |
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
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good fix — matches v54's create-then-alter pattern and
Schema.CreateDatabaseStateExpectedTablealready has both new columns, so the twoALTERs below are no-ops on a fresh create.One thing worth tightening: the
catch { }a few lines below (line ~1419, "Table doesn't exist yet — will be created with the full schema below") is now stale, since the table is unconditionally created right here inside the sametry. If that catch still fires, it's for some other reason, not a missing table. It's also the only bare, non-logging catch among the migration blocks in this file — every sibling (v47, v48, v49, v54) logs via_logger?.LogWarning. Given this whole PR exists to fix a silently-swallowed migration failure (#2748), adding a log line here would surface a future failure of the same shape instead of repeating it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 0ebf8e8 — now logs via
_logger?.LogWarning, matching the sibling migration blocks.