diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 5720e0b..eff9477 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -59,15 +59,16 @@ public class Configuration : IPluginConfiguration // Privacy by Default master switch. Set false to restore upstream behaviour. public bool PrivacyFilterEnabled = true; - // Privacy by Default (DSGVO Art. 25): a config that never met the wizard - // records the player's own conversations and nothing else. Before v1.12.0 - // this started empty, which was harmless only because the failsafe below - // overrode it and stored everything anyway. With the corrected rule an empty - // list means an empty database, so the default has to state the intent. - public HashSet PrivacyPersistChannels = - [ - .. Privacy.PrivacyDefaults.PrivacyFirstWhitelist, - ]; + // Stays empty here. Dalamud deserialises with Json.NET's default settings, + // which means ObjectCreationHandling.Auto: a collection field that already + // holds items is *populated*, not replaced. A non-empty initializer would + // therefore union itself into every config on load and switch channels the + // user had unticked back on. Verified against Newtonsoft 13.0.3: + // saved [] loads as the initializer, saved [Say] loads as initializer + Say. + // + // Privacy by Default (DSGVO Art. 25) is seeded in CreateFresh instead, which + // only runs when there is no config file at all. + public HashSet PrivacyPersistChannels = []; // Failsafe for ChatTypes added by future FFXIV patches. New configs default // to the failsafe via PrivacyDefaults; existing configs keep their saved @@ -82,6 +83,15 @@ public class Configuration : IPluginConfiguration [NonSerialized] private readonly HashSet _warnedUnknownChannels = new(); + // A first-ever start records the player's own conversations and nothing + // else. Deliberately not a field initializer -- see PrivacyPersistChannels. + internal static Configuration CreateFresh() + { + var config = new Configuration(); + config.PrivacyPersistChannels = [.. Privacy.PrivacyDefaults.PrivacyFirstWhitelist]; + return config; + } + public bool IsAllowedForStorage(ChatType type) { if (!PrivacyFilterEnabled) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index 2afee3b..0f390be 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -635,6 +635,41 @@ internal class MessageStore : IDisposable } } + // Hard-deletes every message whose ChatType IS in the list, then VACUUMs. + // Returns the number of rows deleted. + // + // The mirror image of CleanupRetainOnly, and the privacy filter needs both. + // With the unknown-channel failsafe on, the rule keeps every channel this + // build does not recognise -- and a retain-list can only name the ones that + // were already in the database when the list was built, so a channel whose + // first message arrives after that would be deleted. Naming what goes + // instead of what stays removes the window entirely. + internal long CleanupDeleteTypes(IReadOnlyCollection deleteTypes) + { + if (deleteTypes.Count == 0) + return 0; + + lock (_readLock) + { + long deleted; + using (var cmd = Connection.CreateCommand()) + { + var placeholders = BindIntList(cmd, "dt", deleteTypes); + cmd.CommandText = $"DELETE FROM messages WHERE ChatType IN ({placeholders});"; + cmd.CommandTimeout = 600; + deleted = cmd.ExecuteNonQuery(); + } + + if (deleted > 0) + { + InvalidateFtsIndex(); + PerformMaintenance(); + } + + return deleted; + } + } + internal void PerformMaintenance() { lock (_readLock) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index aa4bfaa..a80bcbd 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -251,7 +251,7 @@ public sealed class Plugin : IAsyncDalamudPlugin // Migrate config + database from upstream ChatTwo on first start. MigrateFromChatTwoLayout(); - Config = Interface.GetPluginConfig() as Configuration ?? new Configuration(); + Config = Interface.GetPluginConfig() as Configuration ?? Configuration.CreateFresh(); // PlatformUtil and LogProxy are filled from the DI container in // Phase-1 below (`_host.Services.GetRequiredService()` @@ -724,6 +724,21 @@ public sealed class Plugin : IAsyncDalamudPlugin failure ??= ex; } + // The four long-running workers are background threads with no + // cancellation path, and one of them may be holding an open reader or + // sitting inside a VACUUM. Disposing the store under that tears the + // connection out mid-statement. Five seconds is not a guarantee, but it + // covers everything short of a VACUUM over a very large file, and it + // costs nothing when nothing is running. + var grace = Stopwatch.StartNew(); + while (DbOperations.IsBusy && grace.ElapsedMilliseconds < 5_000) + await Task.Delay(50).ConfigureAwait(false); + + if (DbOperations.IsBusy) + Log.Warning( + $"Disposing while {DbOperations.Current} still owns the store; it outlasted the 5s grace period." + ); + // Container disposes services + windows on the framework thread. // MessageManager.DisposeAsync is not idempotent, so we let the // container do it once instead of double-disposing. diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index ca7653a..e2b1f74 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -298,6 +298,7 @@ internal class HellionStrings internal static string Settings_Database_Stats_Heading => Get(nameof(Settings_Database_Stats_Heading)); internal static string Settings_Database_Busy => Get(nameof(Settings_Database_Busy)); internal static string Settings_Database_ClearHint => Get(nameof(Settings_Database_ClearHint)); + internal static string Settings_Database_ClearError => Get(nameof(Settings_Database_ClearError)); internal static string Settings_Database_Op_RetentionSweep => Get(nameof(Settings_Database_Op_RetentionSweep)); internal static string Settings_Database_Op_Export => Get(nameof(Settings_Database_Op_Export)); internal static string Settings_Database_Op_Cleanup => Get(nameof(Settings_Database_Op_Cleanup)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 70f5adc..b445e98 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1184,4 +1184,7 @@ Ctrl+Maj: executa la neteja de retenció ara mateix en comptes d'esperar el pas diari. Esborra els missatges més antics que els límits de dalt. + + No s'ha pogut esborrar l'historial. No s'ha eliminat res, consulta /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index 58c1546..287615b 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: spustí úklid podle doby uchování hned, místo čekání na denní běh. Smaže zprávy starší než limity výše. + + Vymazání historie selhalo. Nic nebylo odstraněno, viz /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 777e968..6f31c30 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: kører oprydningen med det samme i stedet for at vente på det daglige gennemløb. Sletter beskeder ældre end grænserne ovenfor. + + Sletning af historikken mislykkedes. Intet blev fjernet, se /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index ece9b51..a3a7f54 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1178,4 +1178,7 @@ Strg+Umschalt: führt den Aufbewahrungslauf sofort aus, statt auf den täglichen Durchlauf zu warten. Löscht Nachrichten, die älter sind als die Grenzen oben. + + Das Löschen des Verlaufs ist fehlgeschlagen. Es wurde nichts entfernt, siehe /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index 51a6550..b818787 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: εκτελεί την εκκαθάριση διατήρησης αμέσως, χωρίς να περιμένει το ημερήσιο πέρασμα. Διαγράφει μηνύματα παλαιότερα από τα παραπάνω όρια. + + Η διαγραφή του ιστορικού απέτυχε. Δεν αφαιρέθηκε τίποτα, δείτε /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 89660e9..9f44dd6 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1184,4 +1184,7 @@ Ctrl+Mayús: ejecuta la limpieza de retención ahora mismo en lugar de esperar al barrido diario. Borra los mensajes más antiguos que los límites de arriba. + + No se pudo borrar el historial. No se eliminó nada, consulta /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index 743a5ac..a0a6f20 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1183,4 +1183,7 @@ Ctrl+Vaihto: suorittaa säilytysajon heti sen sijaan, että odottaisi päivittäistä ajoa. Poistaa yllä olevia rajoja vanhemmat viestit. + + Historian tyhjennys epäonnistui. Mitään ei poistettu, katso /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index e95fa64..81dd5f0 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1184,4 +1184,7 @@ Ctrl+Maj : lance le nettoyage de rétention immédiatement au lieu d'attendre le passage quotidien. Supprime les messages plus anciens que les limites ci-dessus. + + L'effacement de l'historique a échoué. Rien n'a été supprimé, voir /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index 289949b..8e023c9 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: azonnal lefuttatja a megőrzési takarítást, nem várja meg a napi futást. Törli a fenti korlátoknál régebbi üzeneteket. + + Az előzmények törlése nem sikerült. Semmi sem lett eltávolítva, lásd /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 254ee3f..f428f96 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1184,4 +1184,7 @@ Ctrl+Maiusc: esegue subito la pulizia di conservazione invece di aspettare il passaggio giornaliero. Cancella i messaggi più vecchi dei limiti sopra. + + La cancellazione della cronologia non è riuscita. Non è stato rimosso nulla, vedi /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index ed0858a..b5c0bd1 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: 毎日の処理を待たずに保存期間の整理をすぐ実行します。上の期限より古いメッセージを削除します。 + + 履歴の削除に失敗しました。何も削除されていません。/xllog を確認してください。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 52c99fe..1e0302e 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: 매일 실행을 기다리지 않고 보존 기간 정리를 지금 실행합니다. 위 기한보다 오래된 메시지를 삭제합니다. + + 기록 삭제에 실패했습니다. 아무것도 삭제되지 않았습니다. /xllog를 확인하세요. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index 27f37ac..3ddc5db 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: kjører oppryddingen med en gang i stedet for å vente på det daglige gjennomløpet. Sletter meldinger eldre enn grensene over. + + Sletting av historikken mislyktes. Ingenting ble fjernet, se /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 763cb82..aa8eb65 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: voert de opschoning nu meteen uit in plaats van te wachten op de dagelijkse ronde. Wist berichten ouder dan de limieten hierboven. + + Het wissen van de geschiedenis is mislukt. Er is niets verwijderd, zie /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index d719b9a..2381382 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: uruchamia porządkowanie od razu, zamiast czekać na codzienny przebieg. Usuwa wiadomości starsze niż limity powyżej. + + Usuwanie historii nie powiodło się. Nic nie zostało usunięte, zobacz /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index d2e5b25..aa86657 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: executa a limpeza de retenção agora em vez de esperar a varredura diária. Apaga mensagens mais antigas que os limites acima. + + Falha ao apagar o histórico. Nada foi removido, veja /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 34b4aae..06c1334 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: executa a limpeza de retenção agora em vez de esperar a varredura diária. Apaga mensagens mais antigas do que os limites acima. + + Falha ao apagar o histórico. Nada foi removido, consulte /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index 9a9583d..9feaf9a 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1204,4 +1204,7 @@ Ctrl+Shift: runs the retention cleanup right now instead of waiting for the daily sweep. Deletes messages older than the limits above. + + Clearing the history failed. Nothing was removed, see /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index 255a0d8..ba41ca9 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: rulează curățarea acum, în loc să aștepte trecerea zilnică. Șterge mesajele mai vechi decât limitele de mai sus. + + Ștergerea istoricului a eșuat. Nu a fost eliminat nimic, vezi /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 5fd3563..04c6f72 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift: выполняет очистку по сроку хранения сразу, не дожидаясь ежедневного прохода. Удаляет сообщения старше указанных выше пределов. + + Не удалось удалить историю. Ничего не было удалено, см. /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index 4c3e621..b8e95b3 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1184,4 +1184,7 @@ Ctrl+Skift: kör gallringen direkt i stället för att vänta på den dagliga körningen. Raderar meddelanden äldre än gränserna ovan. + + Raderingen av historiken misslyckades. Ingenting togs bort, se /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 15de60d..47dc6b3 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: günlük taramayı beklemeden saklama temizliğini hemen çalıştırır. Yukarıdaki sınırlardan eski iletileri siler. + + Geçmiş silinemedi. Hiçbir şey kaldırılmadı, /xllog kaydına bakın. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 6c42f66..51d3331 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1183,4 +1183,7 @@ Ctrl+Shift: виконує очищення за строком зберігання одразу, не чекаючи щоденного проходу. Видаляє повідомлення, старші за вказані вище межі. + + Не вдалося видалити історію. Нічого не було видалено, див. /xllog. + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 8681759..a069fac 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift:立即执行保留期清理,无需等待每日运行。删除早于上方期限的消息。 + + 清空历史记录失败。没有删除任何内容,请查看 /xllog。 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index 2091c3d..5ba3eed 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1184,4 +1184,7 @@ Ctrl+Shift:立即執行保留期清理,無需等待每日執行。刪除早於上方期限的訊息。 + + 清除歷史紀錄失敗。沒有刪除任何內容,請查看 /xllog。 + \ No newline at end of file diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs index 8b459f1..a9e293a 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -44,30 +44,62 @@ internal sealed class DataPrivacyTab // lock, and a VACUUM holds that for the length of a full file rewrite. Once // per frame would not be a stutter, it would be a still image. private long _dbRefreshedAt; + private volatile bool _dbRefreshRunning; + + // Separate from the timestamp so the very first draw can tell "not read yet" + // from "read five seconds ago", and so the throttle cannot swallow the first + // refresh during the machine's first five seconds of uptime. + private volatile bool _dbEverRefreshed; private long _dbSize; private long _dbLogSize; private int _dbMessageCount; private volatile bool _clearRunning; + private volatile bool _maintenanceRunning; + // Shift held while expanding the section. Keeps the developer tools out of // the way without a permanent switch, the way upstream did it. private bool _dbSectionWasOpen; private bool _dbShowAdvanced; + // One answer for the whole tab, not one per section. Cleanup, clear, + // maintenance and export all end up at the same store, and a section that + // only watched its own flag would leave two destructive buttons live at + // once -- the gate turns that into a refusal rather than damage, but a + // refusal the user has to trigger to discover is not an answer. + private DbOperation CurrentOperation => _plugin.DbOperations.Current; + + private bool AnythingRunning => + _exportRunning + || _exportDialogOpen + || _cleanupPreviewRunning + || _cleanupRunning + || _clearRunning + || _maintenanceRunning + || _plugin.RetentionSweepRunning + || CurrentOperation != DbOperation.None; + // What the preview was computed against. All three inputs decide the outcome, // so all three decide whether it is still valid. private sealed record CleanupPreview( IReadOnlyList<(ChatType Type, long Count, bool Keep)> Rows, long KeepCount, long DeleteCount, - IReadOnlyCollection AllowedTypes, + IReadOnlyCollection DeleteTypes, + IReadOnlyCollection RetainTypes, HashSet Listed, bool FilterEnabled, - bool PersistUnknown + bool PersistUnknown, + long Revision ) { - internal bool MatchesConfig() => - FilterEnabled == Plugin.Config.PrivacyFilterEnabled + // Stale on two counts: the settings it was computed against, and the + // database it counted. A retention sweep or a wipe in between leaves the + // numbers describing rows that are already gone, and comparing the + // config alone cannot see that. + internal bool IsCurrent(Plugin plugin) => + Revision == plugin.DbOperations.Revision + && FilterEnabled == Plugin.Config.PrivacyFilterEnabled && PersistUnknown == Plugin.Config.PrivacyPersistUnknownChannels && Listed.SetEquals(Plugin.Config.PrivacyPersistChannels); } @@ -285,9 +317,8 @@ internal sealed class DataPrivacyTab ImGui.Spacing(); var running = _plugin.RetentionSweepRunning; - var current = _plugin.DbOperations.Current; - using (ImRaii.Disabled(running || current != DbOperation.None)) + using (ImRaii.Disabled(AnythingRunning)) { if ( ImGuiUtil.CtrlShiftButton( @@ -300,13 +331,7 @@ internal sealed class DataPrivacyTab // sweep bails silently by design, and a button that does nothing // without saying why is the thing this cycle exists to remove. if (!_plugin.StartRetentionSweep(notify: true)) - WrapperUtil.AddNotification( - string.Format( - HellionStrings.Settings_Database_Busy, - OperationName(_plugin.DbOperations.Current) - ), - NotificationType.Warning - ); + NotifyBusy(); } } @@ -357,12 +382,17 @@ internal sealed class DataPrivacyTab // Beyond the old layout: whoever is about to throw the history away // should see how much of it there is and be told, in the same breath, // that there is a way to keep a copy. - ImGuiUtil.HelpText( - string.Format(HellionStrings.Settings_Database_ClearHint, _dbMessageCount) - ); + // + // Withheld until the count has actually been read. The fields start at + // zero, and "0 messages are stored" in front of the clear button is a + // lie told at the worst possible moment. + if (_dbEverRefreshed) + ImGuiUtil.HelpText( + string.Format(HellionStrings.Settings_Database_ClearHint, _dbMessageCount) + ); - var current = _plugin.DbOperations.Current; - var busy = _clearRunning || current != DbOperation.None; + var current = CurrentOperation; + var busy = AnythingRunning; using (ImRaii.Disabled(busy)) { @@ -386,21 +416,62 @@ internal sealed class DataPrivacyTab DrawAdvancedDatabaseBlock(busy); } - // Suspended while a long-running operation owns the store: MessageCount - // takes the read lock, and asking for it during a VACUUM means waiting for - // the whole file to be rewritten -- on the draw thread. + // MessageCount takes the read lock and COUNT(*) is a full scan in SQLite, so + // this cannot run on the draw thread: checking "is anything busy" first is + // not enough, because an operation can take the lock in the gap between the + // check and the query, and then the game stands still for a whole file + // rewrite. The worker can afford to wait; the frame cannot. + // + // Throttled to once every five seconds, and skipped outright while something + // owns the store -- numbers taken mid-wipe would be wrong by the time they + // are drawn anyway. private void RefreshDatabaseMetadata() { - if (_plugin.DbOperations.IsBusy || _clearRunning) + if (_dbRefreshRunning || AnythingRunning) return; - if (_dbRefreshedAt + 5_000 > Environment.TickCount64) + if (_dbEverRefreshed && _dbRefreshedAt + 5_000 > Environment.TickCount64) return; - _dbSize = _plugin.MessageManager.Store.DatabaseSize(); - _dbLogSize = _plugin.MessageManager.Store.DatabaseLogSize(); - _dbMessageCount = _plugin.MessageManager.Store.MessageCount(); - _dbRefreshedAt = Environment.TickCount64; + _dbRefreshRunning = true; + + var worker = new Thread(() => + { + try + { + _dbSize = _plugin.MessageManager.Store.DatabaseSize(); + _dbLogSize = _plugin.MessageManager.Store.DatabaseLogSize(); + _dbMessageCount = _plugin.MessageManager.Store.MessageCount(); + _dbRefreshedAt = Environment.TickCount64; + _dbEverRefreshed = true; + } + catch (Exception e) + { + _logger.LogError(e, "Reading database metadata failed"); + + // Backs off for the usual interval rather than retrying every + // frame against a store that is unhappy. + _dbRefreshedAt = Environment.TickCount64; + } + finally + { + _dbRefreshRunning = false; + } + }) + { + IsBackground = true, + Name = "HellionChat DB Metadata", + }; + + try + { + worker.Start(); + } + catch (Exception e) + { + _dbRefreshRunning = false; + _logger.LogError(e, "Could not start the metadata thread"); + } } // The old version called ClearMessages straight from the draw thread, VACUUM @@ -419,13 +490,7 @@ internal sealed class DataPrivacyTab { if (!_plugin.DbOperations.TryBegin(DbOperation.Clear)) { - Notify( - string.Format( - HellionStrings.Settings_Database_Busy, - OperationName(_plugin.DbOperations.Current) - ), - NotificationType.Warning - ); + NotifyBusy(); return; } @@ -453,7 +518,7 @@ internal sealed class DataPrivacyTab catch (Exception e) { _logger.LogError(e, "Clearing the database failed"); - Notify(Language.Options_ClearDatabase_Success, NotificationType.Error); + Notify(HellionStrings.Settings_Database_ClearError, NotificationType.Error); } finally { @@ -548,26 +613,43 @@ internal sealed class DataPrivacyTab StartMaintenance(); } - if ( - ImGuiUtil.CtrlShiftButton( - "Reload messages from database", - "Ctrl+Shift: MessageManager.FilterAllTabsAsync()" - ) - ) + // Same guard as its neighbour: it reads the store from the thread pool, + // and doing that during a wipe or a VACUUM is the thing the lock exists + // to prevent. + using (ImRaii.Disabled(busy)) { - _plugin.MessageManager.ClearAllTabs(); - _plugin.MessageManager.FilterAllTabsAsync(); + if ( + ImGuiUtil.CtrlShiftButton( + "Reload messages from database", + "Ctrl+Shift: MessageManager.FilterAllTabsAsync()" + ) + ) + { + _plugin.MessageManager.ClearAllTabs(); + _plugin.MessageManager.FilterAllTabsAsync(); + } } } private void StartMaintenance() { + if (_maintenanceRunning) + return; + + _maintenanceRunning = true; + var worker = new Thread(() => { try { if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) + { + // Said out loud, like every other refusal. A developer tool + // that silently does nothing is how you end up debugging the + // wrong thing. + NotifyBusy(); return; + } try { @@ -585,6 +667,7 @@ internal sealed class DataPrivacyTab finally { _dbRefreshedAt = 0; + _maintenanceRunning = false; } }) { @@ -598,6 +681,7 @@ internal sealed class DataPrivacyTab } catch (Exception e) { + _maintenanceRunning = false; _logger.LogError(e, "Could not start the maintenance thread"); } } @@ -636,8 +720,8 @@ internal sealed class DataPrivacyTab return; } - var current = _plugin.DbOperations.Current; - var busy = _cleanupPreviewRunning || _cleanupRunning || current != DbOperation.None; + var current = CurrentOperation; + var busy = AnythingRunning; ImGui.Spacing(); using (ImRaii.Disabled(busy)) @@ -651,7 +735,7 @@ internal sealed class DataPrivacyTab { ImGuiUtil.HelpText(HellionStrings.Cleanup_NoPreview); } - else if (!preview.MatchesConfig()) + else if (!preview.IsCurrent(_plugin)) { ImGuiUtil.HelpText(HellionStrings.Cleanup_Preview_Stale); } @@ -699,11 +783,15 @@ internal sealed class DataPrivacyTab string.Format(HellionStrings.Cleanup_WillDelete, preview.DeleteCount) ); - using var tree = ImRaii.TreeNode(HellionStrings.Cleanup_Breakdown); + // ### so the open/closed state survives a language switch: ImGui derives + // the node's ID from its label, and a translated label is a new node. + // TreeNode indents on its own, so nothing is pushed on top of it. + using var tree = ImRaii.TreeNode( + $"{HellionStrings.Cleanup_Breakdown}###hc-cleanup-breakdown" + ); if (!tree.Success) return; - using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false); foreach (var (type, count, keep) in preview.Rows) { var marker = keep @@ -715,6 +803,11 @@ internal sealed class DataPrivacyTab // On a worker: the count is a GROUP BY over every stored row, and the old // version ran it inline on the draw thread. + // + // Takes the shared lock even though it only reads. It holds an open reader + // for the length of the scan, and that is exactly what a VACUUM from any of + // the other three operations cannot survive -- which is the reason the lock + // exists at all. private void StartCleanupPreview() { if (_cleanupPreviewRunning) @@ -732,53 +825,20 @@ internal sealed class DataPrivacyTab { try { - using var conn = _plugin.MessageManager.Store.OpenSecondaryConnection(); - var counts = _plugin.MessageManager.Store.GetMessageCountsByChatType(conn); - - var rows = new List<(ChatType, long, bool)>(counts.Count); - long keepCount = 0; - long deleteCount = 0; - - // The allowlist starts from the whitelist itself, not from what - // happens to be stored right now. A channel with zero messages - // at preview time can receive one before the apply lands, and - // deriving the list from the counts would delete it. - var allowed = new HashSet(listed.Select(t => (int)(ushort)t)); - - foreach (var (raw, count) in counts) + if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) { - var type = (ChatType)(ushort)raw; - var known = Enum.IsDefined(type); - var keep = StorageRule.Allows(listed.Contains(type), known, persistUnknown); - - // A stored channel this build does not recognise survives if - // the failsafe says so. That failsafe exists to hold on to a - // new patch's channel until the user decides, and a cleanup - // that deleted it anyway would defeat exactly that. - if (keep) - { - keepCount += count; - allowed.Add(raw); - } - else - { - deleteCount += count; - } - - rows.Add((type, count, keep)); + NotifyBusy(); + return; } - rows.Sort((a, b) => b.Item2.CompareTo(a.Item2)); - - _cleanupPreview = new CleanupPreview( - rows, - keepCount, - deleteCount, - allowed, - listed, - filterEnabled, - persistUnknown - ); + try + { + BuildCleanupPreview(listed, filterEnabled, persistUnknown); + } + finally + { + _plugin.DbOperations.End(DbOperation.Cleanup); + } } catch (Exception e) { @@ -810,6 +870,77 @@ internal sealed class DataPrivacyTab } } + private void BuildCleanupPreview( + HashSet listed, + bool filterEnabled, + bool persistUnknown + ) + { + // Read before the scan. Any operation finishing after this point leaves + // the preview describing rows that may already be gone, and IsCurrent + // will say so. + var revision = _plugin.DbOperations.Revision; + + using var conn = _plugin.MessageManager.Store.OpenSecondaryConnection(); + var counts = _plugin.MessageManager.Store.GetMessageCountsByChatType(conn); + + var rows = new List<(ChatType, long, bool)>(counts.Count); + long keepCount = 0; + long deleteCount = 0; + + foreach (var (raw, count) in counts) + { + var type = (ChatType)(ushort)raw; + var keep = StorageRule.Allows( + listed.Contains(type), + Enum.IsDefined(type), + persistUnknown + ); + + if (keep) + keepCount += count; + else + deleteCount += count; + + rows.Add((type, count, keep)); + } + + rows.Sort((a, b) => b.Item2.CompareTo(a.Item2)); + + // Two shapes, because the two cases genuinely differ. + // + // With the failsafe on, the rule keeps every channel this build does not + // recognise, and there is no way to enumerate those -- so the deletion + // names what goes: known channels that are not on the list. A channel + // whose first message arrives after this preview is therefore safe, and + // so is a listed channel that happens to be empty right now. + // + // With it off, nothing outside the list survives, and a retain-list + // states that exactly. + var deleteTypes = persistUnknown + ? EnumValues + .All.Where(t => !listed.Contains(t)) + .Select(t => (int)(ushort)t) + .ToList() + : (IReadOnlyCollection)Array.Empty(); + + var retainTypes = persistUnknown + ? (IReadOnlyCollection)Array.Empty() + : listed.Select(t => (int)(ushort)t).ToList(); + + _cleanupPreview = new CleanupPreview( + rows, + keepCount, + deleteCount, + deleteTypes, + retainTypes, + listed, + filterEnabled, + persistUnknown, + revision + ); + } + private void StartCleanup(CleanupPreview preview) { if (_cleanupRunning) @@ -819,8 +950,9 @@ internal sealed class DataPrivacyTab // the config. Between the frame that drew the number and the frame that // took the click, nothing can have changed -- but the next revision of // this method should not have to prove that again. - var allowed = preview.AllowedTypes; - if (allowed.Count == 0) + var deleteTypes = preview.DeleteTypes; + var retainTypes = preview.RetainTypes; + if (deleteTypes.Count == 0 && retainTypes.Count == 0) return; _cleanupRunning = true; @@ -831,19 +963,16 @@ internal sealed class DataPrivacyTab { if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) { - Notify( - string.Format( - HellionStrings.Settings_Database_Busy, - OperationName(_plugin.DbOperations.Current) - ), - NotificationType.Warning - ); + NotifyBusy(); return; } try { - var deleted = _plugin.MessageManager.Store.CleanupRetainOnly(allowed); + var deleted = + deleteTypes.Count > 0 + ? _plugin.MessageManager.Store.CleanupDeleteTypes(deleteTypes) + : _plugin.MessageManager.Store.CleanupRetainOnly(retainTypes); _logger.LogInformation($"Privacy cleanup: deleted {deleted} messages"); // The tabs still hold the rows that just left the database. @@ -940,8 +1069,8 @@ internal sealed class DataPrivacyTab // Read once: IsBusy and Current are two reads of the same volatile // field, and between them the operation can finish -- which would print // "another operation is running: " with nothing after the colon. - var current = _plugin.DbOperations.Current; - var blocked = _exportDialogOpen || _exportRunning || current != DbOperation.None; + var current = CurrentOperation; + var blocked = AnythingRunning; ImGui.Spacing(); using (ImRaii.Disabled(blocked)) @@ -1028,6 +1157,22 @@ internal sealed class DataPrivacyTab _ => format.ToString(), }; + // Reads Current once. The guard that sent us here and the name are two + // reads of the same field, and if the other operation finished in between, + // formatting would produce "another operation is running:" with nothing + // after the colon. Nothing to report in that case -- the store is free. + private void NotifyBusy() + { + var op = _plugin.DbOperations.Current; + if (op == DbOperation.None) + return; + + Notify( + string.Format(HellionStrings.Settings_Database_Busy, OperationName(op)), + NotificationType.Warning + ); + } + private static string OperationName(DbOperation op) => op switch { @@ -1052,6 +1197,27 @@ internal sealed class DataPrivacyTab _exportDialogOpen = true; + try + { + OpenExportDialog(format, types, from, sender); + } + catch (Exception e) + { + // Only the callback clears this flag, and a throw here means the + // callback will never run -- which would leave the export button + // dead for the rest of the session. + _exportDialogOpen = false; + _logger.LogError(e, "Could not open the export dialog"); + } + } + + private void OpenExportDialog( + ExportFormat format, + List? types, + DateTimeOffset? from, + string? sender + ) + { Plugin.FileDialogManager.SaveFileDialog( HellionStrings.Export_Dialog_Title, format.Filter(), @@ -1093,13 +1259,7 @@ internal sealed class DataPrivacyTab // write a file nobody is waiting for any more. if (!_plugin.DbOperations.TryBegin(DbOperation.Export)) { - Notify( - string.Format( - HellionStrings.Settings_Database_Busy, - OperationName(_plugin.DbOperations.Current) - ), - NotificationType.Warning - ); + NotifyBusy(); return; } diff --git a/HellionChat/Util/DbOperationGate.cs b/HellionChat/Util/DbOperationGate.cs index 3f6cab6..ffa585a 100644 --- a/HellionChat/Util/DbOperationGate.cs +++ b/HellionChat/Util/DbOperationGate.cs @@ -39,6 +39,14 @@ internal sealed class DbOperationGate internal DbOperation Current => _current; + // Bumped whenever an operation that could have changed rows finishes. A + // cleanup preview snapshots it and treats a mismatch as stale: after a + // retention sweep or a wipe its numbers describe a database that is gone, + // and the comparison against the config alone cannot see that. + private long _revision; + + internal long Revision => Interlocked.Read(ref _revision); + internal bool IsBusy => _current != DbOperation.None; // False when another operation already owns the store. Callers must not @@ -72,8 +80,12 @@ internal sealed class DbOperationGate { lock (_lock) { - if (_current == operation) - _current = DbOperation.None; + if (_current != operation) + return; + + _current = DbOperation.None; + if (operation != DbOperation.Export) + Interlocked.Increment(ref _revision); } } }