Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 76 additions & 26 deletions Lite/Database/DuckDbInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,22 +158,9 @@ public async Task InitializeAsync()
_logger?.LogInformation("Created archive directory: {ArchivePath}", archivePath);
}

/* Try to open the database. If the DuckDB storage version has changed,
this will throw. We handle it by exporting to Parquet, rebuilding, and importing. */
DuckDBConnection connection;
try
{
connection = new DuckDBConnection(ConnectionString);
await connection.OpenAsync();
}
catch (Exception ex) when (IsStorageVersionError(ex))
{
_logger?.LogWarning("DuckDB storage version mismatch detected. Migrating data via Parquet export/import.");
await MigrateViaParquetAsync(archivePath);

connection = new DuckDBConnection(ConnectionString);
await connection.OpenAsync();
}
/* Open the database. Only a genuine storage-version mismatch triggers the
destructive Parquet rebuild; transient lock contention is retried instead. */
DuckDBConnection connection = await OpenDatabaseAsync(archivePath);

using (connection)
{
Expand Down Expand Up @@ -215,21 +202,84 @@ Just create tables with the current schema and stamp the version. */
}

/// <summary>
/// Checks if an exception is a DuckDB storage version mismatch.
/// Opens the DuckDB database, handling the two failure modes distinctly:
/// a genuine storage-version mismatch is migrated via Parquet export/import,
/// while transient lock contention (e.g. an instance that was just killed,
/// or antivirus holding the file) is retried before giving up.
/// Any other open failure is rethrown — it must NOT trigger the destructive
/// Parquet rebuild, which would move the live database aside (Issue #977).
/// </summary>
private async Task<DuckDBConnection> OpenDatabaseAsync(string archivePath)
{
const int maxLockRetries = 5;
const int lockRetryDelayMs = 1000;

for (int attempt = 1; ; attempt++)
{
var connection = new DuckDBConnection(ConnectionString);
try
{
await connection.OpenAsync();
return connection;
}
catch (Exception ex) when (IsStorageVersionError(ex))
{
connection.Dispose();
_logger?.LogWarning("DuckDB storage version mismatch detected. Migrating data via Parquet export/import.");
await MigrateViaParquetAsync(archivePath);

var migrated = new DuckDBConnection(ConnectionString);
await migrated.OpenAsync();
return migrated;
}
catch (Exception ex) when (IsTransientLockError(ex) && attempt < maxLockRetries)
{
connection.Dispose();
_logger?.LogWarning(
"DuckDB database is locked (attempt {Attempt}/{Max}); retrying in {Delay}ms. {Error}",
attempt, maxLockRetries, lockRetryDelayMs, ex.Message);
await Task.Delay(lockRetryDelayMs);
}
catch
{
connection.Dispose();
throw;
}
}
}

/// <summary>
/// Checks if an exception is a genuine DuckDB storage-version mismatch — an
/// incompatible on-disk format that cannot be opened as-is and must be
/// rebuilt. Deliberately narrow: generic open failures and lock contention
/// must NOT match, or the destructive Parquet rebuild fires on a database
/// that is merely locked or recovering a WAL from an unclean shutdown.
/// </summary>
private static bool IsStorageVersionError(Exception ex)
{
/* DuckDB version mismatch errors include:
- "Serialization Error: Failed to deserialize" (incompatible storage format)
- "IO Error: Trying to read a database file with version number X, but we can only read version Y"
Note: Since DuckDB v0.10+, backward compatibility is maintained (newer reads older).
This primarily catches forward-incompatibility (older library, newer file). */
/* DuckDB reports a genuine version mismatch as one of:
- "Serialization Error: Failed to deserialize: ..." (incompatible storage format)
- "IO Error: Trying to read a database file with version number X,
but we can only read version Y"
Since DuckDB v0.10+, newer libraries read older files, so this almost
always means an older library was pointed at a newer file. */
var message = ex.ToString().ToLowerInvariant();
return message.Contains("serialization error")
|| message.Contains("failed to deserialize")
return message.Contains("failed to deserialize")
|| message.Contains("trying to read a database file with version")
|| message.Contains("storage version")
|| message.Contains("unable to open database");
|| message.Contains("storage version");
}

/// <summary>
/// Checks if an exception is transient lock contention on the database file —
/// another process (a just-killed prior instance, antivirus) is holding it.
/// These are safe to retry and must never trigger the Parquet rebuild.
/// </summary>
private static bool IsTransientLockError(Exception ex)
{
var message = ex.ToString().ToLowerInvariant();
return message.Contains("conflicting lock")
|| message.Contains("could not set lock")
|| message.Contains("being used by another process");
}

/// <summary>
Expand Down
Loading