diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index dfaa4f75..10a1a1bb 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -604,14 +604,35 @@ public async Task CompactAsync() } } - /* Swap files: old → backup, compact → primary */ - if (File.Exists(backupPath)) File.Delete(backupPath); - File.Move(_databasePath, backupPath); - + /* Delete WAL files before swap — the old WAL belongs to the pre-compaction + database and would confuse the fresh compacted file on next open */ var walPath = _databasePath + ".wal"; if (File.Exists(walPath)) File.Delete(walPath); - File.Move(tempPath, _databasePath); + var tempWalPath = tempPath + ".wal"; + if (File.Exists(tempWalPath)) File.Delete(tempWalPath); + + /* Atomically replace the database file with the compacted version. + File.Replace swaps in a single OS operation, eliminating any window + where _databasePath doesn't exist (unlike two separate File.Move calls). + Retry briefly if a UI connection still has the file open. */ + if (File.Exists(backupPath)) File.Delete(backupPath); + + const int maxSwapAttempts = 3; + for (int attempt = 1; attempt <= maxSwapAttempts; attempt++) + { + try + { + File.Replace(tempPath, _databasePath, backupPath); + break; + } + catch (IOException) when (attempt < maxSwapAttempts) + { + _logger?.LogDebug("Compaction file swap attempt {Attempt}/{Max} failed (file in use), retrying in 500ms", + attempt, maxSwapAttempts); + await Task.Delay(500); + } + } /* Recreate indexes and views on the fresh database */ using (var connection = CreateConnection()) diff --git a/Lite/Services/CollectionBackgroundService.cs b/Lite/Services/CollectionBackgroundService.cs index cbc9ec1e..1459c936 100644 --- a/Lite/Services/CollectionBackgroundService.cs +++ b/Lite/Services/CollectionBackgroundService.cs @@ -29,9 +29,11 @@ public class CollectionBackgroundService : BackgroundService private readonly ILogger? _logger; private static readonly TimeSpan CollectionInterval = TimeSpan.FromMinutes(1); - private DateTime _lastArchiveTime = DateTime.MinValue; - private DateTime _lastRetentionTime = DateTime.MinValue; - private DateTime _lastCompactionTime = DateTime.MinValue; + /* Start at UtcNow so maintenance tasks don't all fire on the very first cycle. + Archival runs after 1 hour, retention + compaction after 24 hours of uptime. */ + private DateTime _lastArchiveTime = DateTime.UtcNow; + private DateTime _lastRetentionTime = DateTime.UtcNow; + private DateTime _lastCompactionTime = DateTime.UtcNow; /* Archive every hour, retention + compaction once per day */ private static readonly TimeSpan ArchiveInterval = TimeSpan.FromHours(1); @@ -93,10 +95,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) IsCollecting = true; await _collectorService.RunDueCollectorsAsync(stoppingToken); LastCollectionTime = DateTime.UtcNow; - - /* Flush WAL during idle time instead of letting auto-checkpoint - stall collectors mid-write with 2-3s stop-the-world pauses */ - await _collectorService.CheckpointAsync(); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index 9b2e1b3e..b2d9ad39 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -237,6 +237,22 @@ public async Task RunDueCollectorsAsync(CancellationToken cancellationToken = de }, cancellationToken)); await Task.WhenAll(serverTasks); + + /* Run CHECKPOINT here after all collector connections are closed. + This avoids opening a separate DuckDB instance that could conflict + with concurrent UI connections via OS file locks. */ + try + { + using var conn = _duckDb.CreateConnection(); + await conn.OpenAsync(cancellationToken); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CHECKPOINT"; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) + { + _logger?.LogDebug(ex, "Post-collection checkpoint failed (non-critical)"); + } } ///