fix(privacy): close the gaps three review passes found in block A

The worst of them made the block's own privacy promise backwards.

PrivacyPersistChannels was given a non-empty field initializer so a
fresh config would record conversations only. Dalamud deserialises with
Json.NET's defaults, which means ObjectCreationHandling.Auto: a
collection field that already holds items gets *populated*, not
replaced. Verified against Newtonsoft 13.0.3 -- saved [] loads as the
initializer, saved [Say] loads as initializer plus Say. So the change
would have unioned the privacy-first list into every existing config on
load and switched channels back on that the user had unticked, while
also making the v24 migration unreachable and its self-test vacuous. The
field is empty again and the seeding moved to CreateFresh, which only
runs when there is no config file at all.

Cleanup could delete a channel it had promised to keep. The allowlist
could only name channels that were already in the database when the
preview ran, so an unrecognised channel whose first message arrived
afterwards fell outside it. Where the failsafe is on, the deletion now
names what goes -- known channels that are not on the list -- instead of
what stays. The window closes completely, and a listed channel that
happens to be empty right now is safe for the same reason.

The cleanup preview was the one long operation that never took the
shared lock, while holding an open reader across a full-table scan.
That is precisely the case the lock was written for.

Clearing the history reported success when it failed. ClearMessages
purges the full-text index between the delete and the VACUUM; if that
step throws, the plaintext stays on disk and the user was told it was
gone. It has its own error string now, in all 25 languages.

Also:

- One busy state for the whole tab. Cleanup, clear, maintenance and
  export reach the same store, and per-section flags left two
  destructive buttons live at once. The lock turned that into a refusal
  rather than damage, but a refusal you have to trigger to discover is
  not an answer.
- The gate carries a revision, bumped by every mutating operation that
  finishes. A preview taken before a retention sweep no longer passes as
  current afterwards: comparing it against the settings alone cannot see
  that the rows it counted are gone.
- Database metadata moved to a worker. Checking "is anything busy" first
  is not enough, because an operation can take the lock in the gap
  before COUNT(*) runs, and then the game stands still for a whole file
  rewrite.
- The clear hint stays hidden until the count has actually been read.
  "0 messages are stored" in front of the clear button is a lie told at
  the worst possible moment.
- Refusal notices read the operation once. Guard and name were two reads
  of the same field, so a run finishing in between printed a sentence
  that stopped at the colon.
- The retention sweep cannot start twice. The gate only goes busy once
  the worker reaches TryBegin, and the due-check runs every tick.
- Teardown waits up to five seconds for the store to come free rather
  than disposing the connection under a running VACUUM.
- Maintenance has its own flag and says so when it is refused; reload
  gets the same guard as its neighbour; the breakdown tree keeps its
  open state across a language switch.
This commit is contained in:
2026-08-18 22:13:33 +02:00
parent 1987d745d8
commit 0279a1a9d6
31 changed files with 431 additions and 123 deletions
+19 -9
View File
@@ -59,15 +59,16 @@ public class Configuration : IPluginConfiguration
// Privacy by Default master switch. Set false to restore upstream behaviour. // Privacy by Default master switch. Set false to restore upstream behaviour.
public bool PrivacyFilterEnabled = true; public bool PrivacyFilterEnabled = true;
// Privacy by Default (DSGVO Art. 25): a config that never met the wizard // Stays empty here. Dalamud deserialises with Json.NET's default settings,
// records the player's own conversations and nothing else. Before v1.12.0 // which means ObjectCreationHandling.Auto: a collection field that already
// this started empty, which was harmless only because the failsafe below // holds items is *populated*, not replaced. A non-empty initializer would
// overrode it and stored everything anyway. With the corrected rule an empty // therefore union itself into every config on load and switch channels the
// list means an empty database, so the default has to state the intent. // user had unticked back on. Verified against Newtonsoft 13.0.3:
public HashSet<ChatType> PrivacyPersistChannels = // saved [] loads as the initializer, saved [Say] loads as initializer + Say.
[ //
.. Privacy.PrivacyDefaults.PrivacyFirstWhitelist, // Privacy by Default (DSGVO Art. 25) is seeded in CreateFresh instead, which
]; // only runs when there is no config file at all.
public HashSet<ChatType> PrivacyPersistChannels = [];
// Failsafe for ChatTypes added by future FFXIV patches. New configs default // Failsafe for ChatTypes added by future FFXIV patches. New configs default
// to the failsafe via PrivacyDefaults; existing configs keep their saved // to the failsafe via PrivacyDefaults; existing configs keep their saved
@@ -82,6 +83,15 @@ public class Configuration : IPluginConfiguration
[NonSerialized] [NonSerialized]
private readonly HashSet<ChatType> _warnedUnknownChannels = new(); private readonly HashSet<ChatType> _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) public bool IsAllowedForStorage(ChatType type)
{ {
if (!PrivacyFilterEnabled) if (!PrivacyFilterEnabled)
+35
View File
@@ -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<int> 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() internal void PerformMaintenance()
{ {
lock (_readLock) lock (_readLock)
+16 -1
View File
@@ -251,7 +251,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
// Migrate config + database from upstream ChatTwo on first start. // Migrate config + database from upstream ChatTwo on first start.
MigrateFromChatTwoLayout(); 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 // PlatformUtil and LogProxy are filled from the DI container in
// Phase-1 below (`_host.Services.GetRequiredService<IPlatformUtil>()` // Phase-1 below (`_host.Services.GetRequiredService<IPlatformUtil>()`
@@ -724,6 +724,21 @@ public sealed class Plugin : IAsyncDalamudPlugin
failure ??= ex; 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. // Container disposes services + windows on the framework thread.
// MessageManager.DisposeAsync is not idempotent, so we let the // MessageManager.DisposeAsync is not idempotent, so we let the
// container do it once instead of double-disposing. // container do it once instead of double-disposing.
+1
View File
@@ -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_Stats_Heading => Get(nameof(Settings_Database_Stats_Heading));
internal static string Settings_Database_Busy => Get(nameof(Settings_Database_Busy)); 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_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_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_Export => Get(nameof(Settings_Database_Op_Export));
internal static string Settings_Database_Op_Cleanup => Get(nameof(Settings_Database_Op_Cleanup)); internal static string Settings_Database_Op_Cleanup => Get(nameof(Settings_Database_Op_Cleanup));
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>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.</value> <value>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.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>No s'ha pogut esborrar l'historial. No s'ha eliminat res, consulta /xllog.</value>
</data>
</root> </root>
@@ -1183,4 +1183,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>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.</value> <value>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.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Vymazání historie selhalo. Nic nebylo odstraněno, viz /xllog.</value>
</data>
</root> </root>
@@ -1183,4 +1183,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>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.</value> <value>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.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Sletning af historikken mislykkedes. Intet blev fjernet, se /xllog.</value>
</data>
</root> </root>
@@ -1178,4 +1178,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>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.</value> <value>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.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Das Löschen des Verlaufs ist fehlgeschlagen. Es wurde nichts entfernt, siehe /xllog.</value>
</data>
</root> </root>
@@ -1183,4 +1183,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: εκτελεί την εκκαθάριση διατήρησης αμέσως, χωρίς να περιμένει το ημερήσιο πέρασμα. Διαγράφει μηνύματα παλαιότερα από τα παραπάνω όρια.</value> <value>Ctrl+Shift: εκτελεί την εκκαθάριση διατήρησης αμέσως, χωρίς να περιμένει το ημερήσιο πέρασμα. Διαγράφει μηνύματα παλαιότερα από τα παραπάνω όρια.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Η διαγραφή του ιστορικού απέτυχε. Δεν αφαιρέθηκε τίποτα, δείτε /xllog.</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>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.</value> <value>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.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>No se pudo borrar el historial. No se eliminó nada, consulta /xllog.</value>
</data>
</root> </root>
@@ -1183,4 +1183,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Vaihto: suorittaa säilytysajon heti sen sijaan, että odottaisi päivittäistä ajoa. Poistaa yllä olevia rajoja vanhemmat viestit.</value> <value>Ctrl+Vaihto: suorittaa säilytysajon heti sen sijaan, että odottaisi päivittäistä ajoa. Poistaa yllä olevia rajoja vanhemmat viestit.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Historian tyhjennys epäonnistui. Mitään ei poistettu, katso /xllog.</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>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.</value> <value>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.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>L'effacement de l'historique a échoué. Rien n'a été supprimé, voir /xllog.</value>
</data>
</root> </root>
@@ -1183,4 +1183,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>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.</value> <value>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.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Az előzmények törlése nem sikerült. Semmi sem lett eltávolítva, lásd /xllog.</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Maiusc: esegue subito la pulizia di conservazione invece di aspettare il passaggio giornaliero. Cancella i messaggi più vecchi dei limiti sopra.</value> <value>Ctrl+Maiusc: esegue subito la pulizia di conservazione invece di aspettare il passaggio giornaliero. Cancella i messaggi più vecchi dei limiti sopra.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>La cancellazione della cronologia non è riuscita. Non è stato rimosso nulla, vedi /xllog.</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: 毎日の処理を待たずに保存期間の整理をすぐ実行します。上の期限より古いメッセージを削除します。</value> <value>Ctrl+Shift: 毎日の処理を待たずに保存期間の整理をすぐ実行します。上の期限より古いメッセージを削除します。</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>履歴の削除に失敗しました。何も削除されていません。/xllog を確認してください。</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: 매일 실행을 기다리지 않고 보존 기간 정리를 지금 실행합니다. 위 기한보다 오래된 메시지를 삭제합니다.</value> <value>Ctrl+Shift: 매일 실행을 기다리지 않고 보존 기간 정리를 지금 실행합니다. 위 기한보다 오래된 메시지를 삭제합니다.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>기록 삭제에 실패했습니다. 아무것도 삭제되지 않았습니다. /xllog를 확인하세요.</value>
</data>
</root> </root>
@@ -1183,4 +1183,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: kjører oppryddingen med en gang i stedet for å vente på det daglige gjennomløpet. Sletter meldinger eldre enn grensene over.</value> <value>Ctrl+Shift: kjører oppryddingen med en gang i stedet for å vente på det daglige gjennomløpet. Sletter meldinger eldre enn grensene over.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Sletting av historikken mislyktes. Ingenting ble fjernet, se /xllog.</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: voert de opschoning nu meteen uit in plaats van te wachten op de dagelijkse ronde. Wist berichten ouder dan de limieten hierboven.</value> <value>Ctrl+Shift: voert de opschoning nu meteen uit in plaats van te wachten op de dagelijkse ronde. Wist berichten ouder dan de limieten hierboven.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Het wissen van de geschiedenis is mislukt. Er is niets verwijderd, zie /xllog.</value>
</data>
</root> </root>
@@ -1183,4 +1183,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: uruchamia porządkowanie od razu, zamiast czekać na codzienny przebieg. Usuwa wiadomości starsze niż limity powyżej.</value> <value>Ctrl+Shift: uruchamia porządkowanie od razu, zamiast czekać na codzienny przebieg. Usuwa wiadomości starsze niż limity powyżej.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Usuwanie historii nie powiodło się. Nic nie zostało usunięte, zobacz /xllog.</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>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.</value> <value>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.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Falha ao apagar o histórico. Nada foi removido, veja /xllog.</value>
</data>
</root> </root>
@@ -1183,4 +1183,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>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.</value> <value>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.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Falha ao apagar o histórico. Nada foi removido, consulte /xllog.</value>
</data>
</root> </root>
@@ -1204,4 +1204,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: runs the retention cleanup right now instead of waiting for the daily sweep. Deletes messages older than the limits above.</value> <value>Ctrl+Shift: runs the retention cleanup right now instead of waiting for the daily sweep. Deletes messages older than the limits above.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Clearing the history failed. Nothing was removed, see /xllog.</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: rulează curățarea acum, în loc să aștepte trecerea zilnică. Șterge mesajele mai vechi decât limitele de mai sus.</value> <value>Ctrl+Shift: rulează curățarea acum, în loc să aștepte trecerea zilnică. Șterge mesajele mai vechi decât limitele de mai sus.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Ștergerea istoricului a eșuat. Nu a fost eliminat nimic, vezi /xllog.</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: выполняет очистку по сроку хранения сразу, не дожидаясь ежедневного прохода. Удаляет сообщения старше указанных выше пределов.</value> <value>Ctrl+Shift: выполняет очистку по сроку хранения сразу, не дожидаясь ежедневного прохода. Удаляет сообщения старше указанных выше пределов.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Не удалось удалить историю. Ничего не было удалено, см. /xllog.</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>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.</value> <value>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.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Raderingen av historiken misslyckades. Ingenting togs bort, se /xllog.</value>
</data>
</root> </root>
@@ -1183,4 +1183,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: günlük taramayı beklemeden saklama temizliğini hemen çalıştırır. Yukarıdaki sınırlardan eski iletileri siler.</value> <value>Ctrl+Shift: günlük taramayı beklemeden saklama temizliğini hemen çalıştırır. Yukarıdaki sınırlardan eski iletileri siler.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Geçmiş silinemedi. Hiçbir şey kaldırılmadı, /xllog kaydına bakın.</value>
</data>
</root> </root>
@@ -1183,4 +1183,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift: виконує очищення за строком зберігання одразу, не чекаючи щоденного проходу. Видаляє повідомлення, старші за вказані вище межі.</value> <value>Ctrl+Shift: виконує очищення за строком зберігання одразу, не чекаючи щоденного проходу. Видаляє повідомлення, старші за вказані вище межі.</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>Не вдалося видалити історію. Нічого не було видалено, див. /xllog.</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift:立即执行保留期清理,无需等待每日运行。删除早于上方期限的消息。</value> <value>Ctrl+Shift:立即执行保留期清理,无需等待每日运行。删除早于上方期限的消息。</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>清空历史记录失败。没有删除任何内容,请查看 /xllog。</value>
</data>
</root> </root>
@@ -1184,4 +1184,7 @@
<data name="Retention_RunNow_Tooltip" xml:space="preserve"> <data name="Retention_RunNow_Tooltip" xml:space="preserve">
<value>Ctrl+Shift:立即執行保留期清理,無需等待每日執行。刪除早於上方期限的訊息。</value> <value>Ctrl+Shift:立即執行保留期清理,無需等待每日執行。刪除早於上方期限的訊息。</value>
</data> </data>
<data name="Settings_Database_ClearError" xml:space="preserve">
<value>清除歷史紀錄失敗。沒有刪除任何內容,請查看 /xllog。</value>
</data>
</root> </root>
@@ -44,30 +44,62 @@ internal sealed class DataPrivacyTab
// lock, and a VACUUM holds that for the length of a full file rewrite. Once // 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. // per frame would not be a stutter, it would be a still image.
private long _dbRefreshedAt; 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 _dbSize;
private long _dbLogSize; private long _dbLogSize;
private int _dbMessageCount; private int _dbMessageCount;
private volatile bool _clearRunning; private volatile bool _clearRunning;
private volatile bool _maintenanceRunning;
// Shift held while expanding the section. Keeps the developer tools out of // Shift held while expanding the section. Keeps the developer tools out of
// the way without a permanent switch, the way upstream did it. // the way without a permanent switch, the way upstream did it.
private bool _dbSectionWasOpen; private bool _dbSectionWasOpen;
private bool _dbShowAdvanced; 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, // What the preview was computed against. All three inputs decide the outcome,
// so all three decide whether it is still valid. // so all three decide whether it is still valid.
private sealed record CleanupPreview( private sealed record CleanupPreview(
IReadOnlyList<(ChatType Type, long Count, bool Keep)> Rows, IReadOnlyList<(ChatType Type, long Count, bool Keep)> Rows,
long KeepCount, long KeepCount,
long DeleteCount, long DeleteCount,
IReadOnlyCollection<int> AllowedTypes, IReadOnlyCollection<int> DeleteTypes,
IReadOnlyCollection<int> RetainTypes,
HashSet<ChatType> Listed, HashSet<ChatType> Listed,
bool FilterEnabled, bool FilterEnabled,
bool PersistUnknown bool PersistUnknown,
long Revision
) )
{ {
internal bool MatchesConfig() => // Stale on two counts: the settings it was computed against, and the
FilterEnabled == Plugin.Config.PrivacyFilterEnabled // 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 && PersistUnknown == Plugin.Config.PrivacyPersistUnknownChannels
&& Listed.SetEquals(Plugin.Config.PrivacyPersistChannels); && Listed.SetEquals(Plugin.Config.PrivacyPersistChannels);
} }
@@ -285,9 +317,8 @@ internal sealed class DataPrivacyTab
ImGui.Spacing(); ImGui.Spacing();
var running = _plugin.RetentionSweepRunning; var running = _plugin.RetentionSweepRunning;
var current = _plugin.DbOperations.Current;
using (ImRaii.Disabled(running || current != DbOperation.None)) using (ImRaii.Disabled(AnythingRunning))
{ {
if ( if (
ImGuiUtil.CtrlShiftButton( ImGuiUtil.CtrlShiftButton(
@@ -300,13 +331,7 @@ internal sealed class DataPrivacyTab
// sweep bails silently by design, and a button that does nothing // sweep bails silently by design, and a button that does nothing
// without saying why is the thing this cycle exists to remove. // without saying why is the thing this cycle exists to remove.
if (!_plugin.StartRetentionSweep(notify: true)) if (!_plugin.StartRetentionSweep(notify: true))
WrapperUtil.AddNotification( NotifyBusy();
string.Format(
HellionStrings.Settings_Database_Busy,
OperationName(_plugin.DbOperations.Current)
),
NotificationType.Warning
);
} }
} }
@@ -357,12 +382,17 @@ internal sealed class DataPrivacyTab
// Beyond the old layout: whoever is about to throw the history away // 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, // should see how much of it there is and be told, in the same breath,
// that there is a way to keep a copy. // 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 current = CurrentOperation;
var busy = _clearRunning || current != DbOperation.None; var busy = AnythingRunning;
using (ImRaii.Disabled(busy)) using (ImRaii.Disabled(busy))
{ {
@@ -386,21 +416,62 @@ internal sealed class DataPrivacyTab
DrawAdvancedDatabaseBlock(busy); DrawAdvancedDatabaseBlock(busy);
} }
// Suspended while a long-running operation owns the store: MessageCount // MessageCount takes the read lock and COUNT(*) is a full scan in SQLite, so
// takes the read lock, and asking for it during a VACUUM means waiting for // this cannot run on the draw thread: checking "is anything busy" first is
// the whole file to be rewritten -- on the draw thread. // 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() private void RefreshDatabaseMetadata()
{ {
if (_plugin.DbOperations.IsBusy || _clearRunning) if (_dbRefreshRunning || AnythingRunning)
return; return;
if (_dbRefreshedAt + 5_000 > Environment.TickCount64) if (_dbEverRefreshed && _dbRefreshedAt + 5_000 > Environment.TickCount64)
return; return;
_dbSize = _plugin.MessageManager.Store.DatabaseSize(); _dbRefreshRunning = true;
_dbLogSize = _plugin.MessageManager.Store.DatabaseLogSize();
_dbMessageCount = _plugin.MessageManager.Store.MessageCount(); var worker = new Thread(() =>
_dbRefreshedAt = Environment.TickCount64; {
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 // 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)) if (!_plugin.DbOperations.TryBegin(DbOperation.Clear))
{ {
Notify( NotifyBusy();
string.Format(
HellionStrings.Settings_Database_Busy,
OperationName(_plugin.DbOperations.Current)
),
NotificationType.Warning
);
return; return;
} }
@@ -453,7 +518,7 @@ internal sealed class DataPrivacyTab
catch (Exception e) catch (Exception e)
{ {
_logger.LogError(e, "Clearing the database failed"); _logger.LogError(e, "Clearing the database failed");
Notify(Language.Options_ClearDatabase_Success, NotificationType.Error); Notify(HellionStrings.Settings_Database_ClearError, NotificationType.Error);
} }
finally finally
{ {
@@ -548,26 +613,43 @@ internal sealed class DataPrivacyTab
StartMaintenance(); StartMaintenance();
} }
if ( // Same guard as its neighbour: it reads the store from the thread pool,
ImGuiUtil.CtrlShiftButton( // and doing that during a wipe or a VACUUM is the thing the lock exists
"Reload messages from database", // to prevent.
"Ctrl+Shift: MessageManager.FilterAllTabsAsync()" using (ImRaii.Disabled(busy))
)
)
{ {
_plugin.MessageManager.ClearAllTabs(); if (
_plugin.MessageManager.FilterAllTabsAsync(); ImGuiUtil.CtrlShiftButton(
"Reload messages from database",
"Ctrl+Shift: MessageManager.FilterAllTabsAsync()"
)
)
{
_plugin.MessageManager.ClearAllTabs();
_plugin.MessageManager.FilterAllTabsAsync();
}
} }
} }
private void StartMaintenance() private void StartMaintenance()
{ {
if (_maintenanceRunning)
return;
_maintenanceRunning = true;
var worker = new Thread(() => var worker = new Thread(() =>
{ {
try try
{ {
if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) 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; return;
}
try try
{ {
@@ -585,6 +667,7 @@ internal sealed class DataPrivacyTab
finally finally
{ {
_dbRefreshedAt = 0; _dbRefreshedAt = 0;
_maintenanceRunning = false;
} }
}) })
{ {
@@ -598,6 +681,7 @@ internal sealed class DataPrivacyTab
} }
catch (Exception e) catch (Exception e)
{ {
_maintenanceRunning = false;
_logger.LogError(e, "Could not start the maintenance thread"); _logger.LogError(e, "Could not start the maintenance thread");
} }
} }
@@ -636,8 +720,8 @@ internal sealed class DataPrivacyTab
return; return;
} }
var current = _plugin.DbOperations.Current; var current = CurrentOperation;
var busy = _cleanupPreviewRunning || _cleanupRunning || current != DbOperation.None; var busy = AnythingRunning;
ImGui.Spacing(); ImGui.Spacing();
using (ImRaii.Disabled(busy)) using (ImRaii.Disabled(busy))
@@ -651,7 +735,7 @@ internal sealed class DataPrivacyTab
{ {
ImGuiUtil.HelpText(HellionStrings.Cleanup_NoPreview); ImGuiUtil.HelpText(HellionStrings.Cleanup_NoPreview);
} }
else if (!preview.MatchesConfig()) else if (!preview.IsCurrent(_plugin))
{ {
ImGuiUtil.HelpText(HellionStrings.Cleanup_Preview_Stale); ImGuiUtil.HelpText(HellionStrings.Cleanup_Preview_Stale);
} }
@@ -699,11 +783,15 @@ internal sealed class DataPrivacyTab
string.Format(HellionStrings.Cleanup_WillDelete, preview.DeleteCount) 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) if (!tree.Success)
return; return;
using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false);
foreach (var (type, count, keep) in preview.Rows) foreach (var (type, count, keep) in preview.Rows)
{ {
var marker = keep 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 // On a worker: the count is a GROUP BY over every stored row, and the old
// version ran it inline on the draw thread. // 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() private void StartCleanupPreview()
{ {
if (_cleanupPreviewRunning) if (_cleanupPreviewRunning)
@@ -732,53 +825,20 @@ internal sealed class DataPrivacyTab
{ {
try try
{ {
using var conn = _plugin.MessageManager.Store.OpenSecondaryConnection(); if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup))
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<int>(listed.Select(t => (int)(ushort)t));
foreach (var (raw, count) in counts)
{ {
var type = (ChatType)(ushort)raw; NotifyBusy();
var known = Enum.IsDefined(type); return;
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));
} }
rows.Sort((a, b) => b.Item2.CompareTo(a.Item2)); try
{
_cleanupPreview = new CleanupPreview( BuildCleanupPreview(listed, filterEnabled, persistUnknown);
rows, }
keepCount, finally
deleteCount, {
allowed, _plugin.DbOperations.End(DbOperation.Cleanup);
listed, }
filterEnabled,
persistUnknown
);
} }
catch (Exception e) catch (Exception e)
{ {
@@ -810,6 +870,77 @@ internal sealed class DataPrivacyTab
} }
} }
private void BuildCleanupPreview(
HashSet<ChatType> 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<ChatType>
.All.Where(t => !listed.Contains(t))
.Select(t => (int)(ushort)t)
.ToList()
: (IReadOnlyCollection<int>)Array.Empty<int>();
var retainTypes = persistUnknown
? (IReadOnlyCollection<int>)Array.Empty<int>()
: listed.Select(t => (int)(ushort)t).ToList();
_cleanupPreview = new CleanupPreview(
rows,
keepCount,
deleteCount,
deleteTypes,
retainTypes,
listed,
filterEnabled,
persistUnknown,
revision
);
}
private void StartCleanup(CleanupPreview preview) private void StartCleanup(CleanupPreview preview)
{ {
if (_cleanupRunning) if (_cleanupRunning)
@@ -819,8 +950,9 @@ internal sealed class DataPrivacyTab
// the config. Between the frame that drew the number and the frame that // 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 // took the click, nothing can have changed -- but the next revision of
// this method should not have to prove that again. // this method should not have to prove that again.
var allowed = preview.AllowedTypes; var deleteTypes = preview.DeleteTypes;
if (allowed.Count == 0) var retainTypes = preview.RetainTypes;
if (deleteTypes.Count == 0 && retainTypes.Count == 0)
return; return;
_cleanupRunning = true; _cleanupRunning = true;
@@ -831,19 +963,16 @@ internal sealed class DataPrivacyTab
{ {
if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup)) if (!_plugin.DbOperations.TryBegin(DbOperation.Cleanup))
{ {
Notify( NotifyBusy();
string.Format(
HellionStrings.Settings_Database_Busy,
OperationName(_plugin.DbOperations.Current)
),
NotificationType.Warning
);
return; return;
} }
try 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"); _logger.LogInformation($"Privacy cleanup: deleted {deleted} messages");
// The tabs still hold the rows that just left the database. // 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 // Read once: IsBusy and Current are two reads of the same volatile
// field, and between them the operation can finish -- which would print // field, and between them the operation can finish -- which would print
// "another operation is running: " with nothing after the colon. // "another operation is running: " with nothing after the colon.
var current = _plugin.DbOperations.Current; var current = CurrentOperation;
var blocked = _exportDialogOpen || _exportRunning || current != DbOperation.None; var blocked = AnythingRunning;
ImGui.Spacing(); ImGui.Spacing();
using (ImRaii.Disabled(blocked)) using (ImRaii.Disabled(blocked))
@@ -1028,6 +1157,22 @@ internal sealed class DataPrivacyTab
_ => format.ToString(), _ => 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) => private static string OperationName(DbOperation op) =>
op switch op switch
{ {
@@ -1052,6 +1197,27 @@ internal sealed class DataPrivacyTab
_exportDialogOpen = true; _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<int>? types,
DateTimeOffset? from,
string? sender
)
{
Plugin.FileDialogManager.SaveFileDialog( Plugin.FileDialogManager.SaveFileDialog(
HellionStrings.Export_Dialog_Title, HellionStrings.Export_Dialog_Title,
format.Filter(), format.Filter(),
@@ -1093,13 +1259,7 @@ internal sealed class DataPrivacyTab
// write a file nobody is waiting for any more. // write a file nobody is waiting for any more.
if (!_plugin.DbOperations.TryBegin(DbOperation.Export)) if (!_plugin.DbOperations.TryBegin(DbOperation.Export))
{ {
Notify( NotifyBusy();
string.Format(
HellionStrings.Settings_Database_Busy,
OperationName(_plugin.DbOperations.Current)
),
NotificationType.Warning
);
return; return;
} }
+14 -2
View File
@@ -39,6 +39,14 @@ internal sealed class DbOperationGate
internal DbOperation Current => _current; 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; internal bool IsBusy => _current != DbOperation.None;
// False when another operation already owns the store. Callers must not // False when another operation already owns the store. Callers must not
@@ -72,8 +80,12 @@ internal sealed class DbOperationGate
{ {
lock (_lock) lock (_lock)
{ {
if (_current == operation) if (_current != operation)
_current = DbOperation.None; return;
_current = DbOperation.None;
if (operation != DbOperation.Export)
Interlocked.Increment(ref _revision);
} }
} }
} }