mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 14:18:00 +00:00
Kept the app responsive while a backup copies the index (#1062)
This commit is contained in:
1 parent
936043319c
commit
65857ca845
5 files changed
+64
-28
No files matched your search
@@ -188,9 +188,11 @@ public partial class DataBackupPanel : MSGComponentBase
|
|||||||
if (selectionResponse.UserCancelled)
|
if (selectionResponse.UserCancelled)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.selectedArchivePath = selectionResponse.SelectedFilePath;
|
var archivePath = this.selectedArchivePath = selectionResponse.SelectedFilePath;
|
||||||
this.importResult = null;
|
this.importResult = null;
|
||||||
this.preview = await ChatArchiveImporter.ReadPreviewAsync(this.selectedArchivePath, CancellationToken.None);
|
|
||||||
|
// A large backup lists many thousands of files, and reading a ZIP file is mostly synchronous:
|
||||||
|
this.preview = await Task.Run(() => ChatArchiveImporter.ReadPreviewAsync(archivePath, CancellationToken.None));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ When working on these, keep in mind:
|
|||||||
- **Let every probe in `GetDisplayInfo()` catch its own failure.** When the method throws, the page
|
- **Let every probe in `GetDisplayInfo()` catch its own failure.** When the method throws, the page
|
||||||
replaces the *entire* block with the fallback client, so one unreadable value costs all the others
|
replaces the *entire* block with the fallback client, so one unreadable value costs all the others
|
||||||
as well.
|
as well.
|
||||||
|
- **SQLite runs its async methods synchronously.** `Microsoft.Data.Sqlite`, and EF Core on top of it,
|
||||||
|
only looks asynchronous: the calling thread does all the work. A statement which may take long, such
|
||||||
|
as `VACUUM` or a check of the whole database, runs in `Task.Run` inside the client, as
|
||||||
|
`BackupToDirectoryAsync` does. Otherwise a click in the user interface which reaches it freezes the
|
||||||
|
whole window until the statement is done.
|
||||||
- **Raw SQL against SQLite goes through `context.Database.GetDbConnection()`**, not through
|
- **Raw SQL against SQLite goes through `context.Database.GetDbConnection()`**, not through
|
||||||
`SqlQueryRaw<T>`: that one expects a column named `Value` and wraps the statement, so a `PRAGMA`
|
`SqlQueryRaw<T>`: that one expects a column named `Value` and wraps the statement, so a `PRAGMA`
|
||||||
never works with it.
|
never works with it.
|
||||||
|
|||||||
+7
-2
@@ -590,9 +590,14 @@ public sealed partial class SqliteIndexStoreClientImplementation(string name, st
|
|||||||
/// GetDbConnection. The connections of the context share one cache, and a shared cache locks
|
/// GetDbConnection. The connections of the context share one cache, and a shared cache locks
|
||||||
/// whole tables: reading the whole database for the copy would hold back every writer until the
|
/// whole tables: reading the whole database for the copy would hold back every writer until the
|
||||||
/// copy is done. Without pooling, nothing keeps the copy open afterward, so the backup can
|
/// copy is done. Without pooling, nothing keeps the copy open afterward, so the backup can
|
||||||
/// delete it on every OS.
|
/// delete it on every OS.<br/><br/>
|
||||||
|
///
|
||||||
|
/// SQLite runs its async methods synchronously, and copying and checking a large index takes a
|
||||||
|
/// while. The copy therefore runs on a thread of its own, so that it never holds up its caller.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public override async Task<string> BackupToDirectoryAsync(string targetDirectory, CancellationToken token)
|
public override Task<string> BackupToDirectoryAsync(string targetDirectory, CancellationToken token) => Task.Run(() => this.CopyAndCheckAsync(targetDirectory, token), token);
|
||||||
|
|
||||||
|
private async Task<string> CopyAndCheckAsync(string targetDirectory, CancellationToken token)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(targetDirectory);
|
Directory.CreateDirectory(targetDirectory);
|
||||||
var targetPath = Path.Join(targetDirectory, DATABASE_FILENAME);
|
var targetPath = Path.Join(targetDirectory, DATABASE_FILENAME);
|
||||||
|
|||||||
@@ -110,32 +110,16 @@ public sealed class DataBackupService(ILogger<DataBackupService> logger, Setting
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.ReportProgress(new(DataBackupPhase.PREPARING, 0, 0));
|
this.ReportProgress(new(DataBackupPhase.PREPARING, 0, 0));
|
||||||
var indexCopiesDirectory = string.Empty;
|
|
||||||
var token = cancellation.Token;
|
var token = cancellation.Token;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var dataDirectory = SettingsManager.DataDirectory ?? throw new InvalidOperationException("The data directory is not known yet.");
|
//
|
||||||
var configDirectory = SettingsManager.ConfigDirectory ?? throw new InvalidOperationException("The configuration directory is not known yet.");
|
// Copying the settings and the index of the data sources takes a while, and SQLite does
|
||||||
var settings = DataBackupSettings.CopyWithoutPluginObjects(settingsManager.ConfigurationData);
|
// its work synchronously, even behind its async methods. The click which starts a backup,
|
||||||
var settingsJson = JsonSerializer.Serialize(settings, SettingsManager.JSON_OPTIONS);
|
// though, arrives on the thread of the user interface, which would stand still meanwhile:
|
||||||
var organizationPluginFolders = DataBackupContents.FindOrganizationPluginsAmongUserPlugins(PluginFactory.AvailablePlugins.OfType<IAvailablePlugin>(), dataDirectory);
|
//
|
||||||
|
|
||||||
indexCopiesDirectory = await this.CopyIndexesAsync(token);
|
|
||||||
|
|
||||||
var sources = new DataBackupSources(dataDirectory, configDirectory, settingsJson, indexCopiesDirectory, organizationPluginFolders);
|
|
||||||
var manifest = new DataBackupManifest(
|
|
||||||
DataBackupFormat.FORMAT_VERSION,
|
|
||||||
Assembly.GetExecutingAssembly().GetCustomAttribute<MetaDataAttribute>()?.Version ?? "unknown",
|
|
||||||
settings.Version.ToString(),
|
|
||||||
DateTimeOffset.UtcNow,
|
|
||||||
dataDirectory,
|
|
||||||
configDirectory,
|
|
||||||
!string.IsNullOrWhiteSpace(indexCopiesDirectory),
|
|
||||||
[]);
|
|
||||||
|
|
||||||
var progress = new Progress<DataBackupProgress>(this.ReportProgress);
|
var progress = new Progress<DataBackupProgress>(this.ReportProgress);
|
||||||
await using (var archiveStream = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
var manifest = await Task.Run(() => this.WriteBackupAsync(archivePath, progress, token), token);
|
||||||
manifest = await Task.Run(() => DataBackupWriter.WriteAsync(archiveStream, sources, manifest, progress, token), token);
|
|
||||||
|
|
||||||
logger.LogInformation("Backed up {FileCount} files with {Megabytes:F1} MB into '{ArchivePath}'. The indexes of the data sources are included: {IncludesIndexes}.", manifest.Entries.Count, manifest.Entries.Sum(entry => entry.Size) / (1024d * 1024d), archivePath, manifest.IncludesDataSourceIndexes);
|
logger.LogInformation("Backed up {FileCount} files with {Megabytes:F1} MB into '{ArchivePath}'. The indexes of the data sources are included: {IncludesIndexes}.", manifest.Entries.Count, manifest.Entries.Sum(entry => entry.Size) / (1024d * 1024d), archivePath, manifest.IncludesDataSourceIndexes);
|
||||||
return this.End(new(true, false, archivePath, manifest.Entries.Count, manifest.Entries.Sum(entry => entry.Size), manifest.IncludesDataSourceIndexes, string.Empty));
|
return this.End(new(true, false, archivePath, manifest.Entries.Count, manifest.Entries.Sum(entry => entry.Size), manifest.IncludesDataSourceIndexes, string.Empty));
|
||||||
@@ -160,7 +144,6 @@ public sealed class DataBackupService(ILogger<DataBackupService> logger, Setting
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
this.TryDeleteIndexCopies(indexCopiesDirectory);
|
|
||||||
lock (this.stateLock)
|
lock (this.stateLock)
|
||||||
this.running = null;
|
this.running = null;
|
||||||
|
|
||||||
@@ -169,6 +152,46 @@ public sealed class DataBackupService(ILogger<DataBackupService> logger, Setting
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies the settings and the indexes, and writes them into the backup together with the rest of the data.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="archivePath">The file of the backup.</param>
|
||||||
|
/// <param name="progress">Receives how far writing got.</param>
|
||||||
|
/// <param name="token">Cancels the backup.</param>
|
||||||
|
/// <returns>The manifest of the backup, as written into it.</returns>
|
||||||
|
private async Task<DataBackupManifest> WriteBackupAsync(string archivePath, IProgress<DataBackupProgress> progress, CancellationToken token)
|
||||||
|
{
|
||||||
|
var indexCopiesDirectory = string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dataDirectory = SettingsManager.DataDirectory ?? throw new InvalidOperationException("The data directory is not known yet.");
|
||||||
|
var configDirectory = SettingsManager.ConfigDirectory ?? throw new InvalidOperationException("The configuration directory is not known yet.");
|
||||||
|
var settings = DataBackupSettings.CopyWithoutPluginObjects(settingsManager.ConfigurationData);
|
||||||
|
var settingsJson = JsonSerializer.Serialize(settings, SettingsManager.JSON_OPTIONS);
|
||||||
|
var organizationPluginFolders = DataBackupContents.FindOrganizationPluginsAmongUserPlugins(PluginFactory.AvailablePlugins.OfType<IAvailablePlugin>(), dataDirectory);
|
||||||
|
|
||||||
|
indexCopiesDirectory = await this.CopyIndexesAsync(token);
|
||||||
|
|
||||||
|
var sources = new DataBackupSources(dataDirectory, configDirectory, settingsJson, indexCopiesDirectory, organizationPluginFolders);
|
||||||
|
var manifest = new DataBackupManifest(
|
||||||
|
DataBackupFormat.FORMAT_VERSION,
|
||||||
|
Assembly.GetExecutingAssembly().GetCustomAttribute<MetaDataAttribute>()?.Version ?? "unknown",
|
||||||
|
settings.Version.ToString(),
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
dataDirectory,
|
||||||
|
configDirectory,
|
||||||
|
!string.IsNullOrWhiteSpace(indexCopiesDirectory),
|
||||||
|
[]);
|
||||||
|
|
||||||
|
await using var archiveStream = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None, BUFFER_SIZE, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
|
return await DataBackupWriter.WriteAsync(archiveStream, sources, manifest, progress, token);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
this.TryDeleteIndexCopies(indexCopiesDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether writing failed since the file system takes no file this large, as FAT32 does beyond 4 GiB.
|
/// Whether writing failed since the file system takes no file this large, as FAT32 does beyond 4 GiB.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -324,7 +324,8 @@ public sealed class DataRestoreService(ILogger<DataRestoreService> logger, Setti
|
|||||||
var target = this.CreateTarget();
|
var target = this.CreateTarget();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DataRestorePreparer.Discard(target);
|
// The unpacked backup may hold thousands of files, and the click to discard it arrives on the thread of the user interface:
|
||||||
|
await Task.Run(() => DataRestorePreparer.Discard(target));
|
||||||
logger.LogInformation("Discarded the prepared restore.");
|
logger.LogInformation("Discarded the prepared restore.");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
Reference in new issue
Block a user