feat(privacy): reconnect database maintenance and the manual retention run

Two sections that had backends and no buttons.

Database: path, size, WAL size, message count, and a clear button. The
numbers refresh at most every five seconds and not at all while a long
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. The old version called ClearMessages straight from
the draw thread, VACUUM included; it runs on a worker now.

One line beyond the old layout sits above the clear button: how many
messages are stored, and that exporting keeps a copy. Whoever is about
to throw the history away should be told there is a way not to.

The legacy Chat 2 files only get a block when they are actually on disk,
and the advanced tools only appear when the section is expanded with
Shift held. The message injector is not back: it was deleted with the
tab and writing 10,000 fake messages into a user's real database is not
something to rebuild on the way past.

Retention: an "apply now" button, the running hint, and the last-run
line, which v1.11.0 shipped as an English literal while both strings sat
translated in all 25 languages. Plus reset-to-spec next to the existing
clear-overrides, since the two answer different questions and both were
already translated.

Retention_Apply_Tooltip stays unused and gets a replacement. It ends
with "Save your changes first", and the window it was written for had a
Save button.

Also here, found while wiring the manual trigger:

DbOperationGate.End now takes the operation it releases. It used to
reset blindly, on the reasoning that a worker must be able to release
from a finally without knowing whether it acquired. That is backwards: a
worker whose TryBegin was refused also runs its finally, and a blind
reset there hands away the lock of whichever operation actually holds
it. Worse than no gate, because the refused worker walks off believing
it did nothing while a VACUUM starts under somebody's open reader.
This commit is contained in:
2026-08-18 21:47:50 +02:00
parent 1ab7ba8377
commit e24ea79302
29 changed files with 646 additions and 48 deletions
+91 -35
View File
@@ -1010,6 +1010,22 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24))
return;
StartRetentionSweep(notify: false);
}
// Shared by the daily check above and the manual button in settings.
//
// notify: the unattended sweep stays quiet, because a notification for
// something the user did not ask for at a moment they did not choose is
// noise. A run they pressed a button for reports back.
//
// Returns false when the store is already busy, so the caller can say so
// instead of leaving the user waiting for a run that never started.
internal bool StartRetentionSweep(bool notify)
{
if (DbOperations.IsBusy)
return false;
// Snapshot the policy so the user can edit settings while the sweep runs.
var policy = new Dictionary<int, int>();
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
@@ -1023,67 +1039,107 @@ public sealed class Plugin : IAsyncDalamudPlugin
}
var defaultDays = Config.RetentionDefaultDays;
_retentionSweepRunning = true;
// IsBackground = true so a stuck sweep never blocks plugin unload.
new Thread(() =>
var worker = new Thread(() =>
{
// Bails when anything else already owns the store, not only another
// sweep: a user-triggered export or cleanup counts too.
if (!DbOperations.TryBegin(Util.DbOperation.RetentionSweep))
return;
try
{
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
SaveConfig();
if (!DbOperations.TryBegin(Util.DbOperation.RetentionSweep))
return;
if (deleted > 0)
try
{
Log.Information($"Retention sweep deleted {deleted} expired messages.");
// Schedule on the next framework tick to avoid the ~194ms
// hitch from blocking with .Wait() while the frame finishes.
// The Config.Tabs enumeration in ClearAllTabs/FilterAllTabs is
// now guarded by the shared Plugin.TabsListLock (B3), so this
// tick scheduling is purely hitch-avoidance, not safety.
// Pattern reference: SimpleTweaks
// Tweaks/Chat/CaseInsensitiveCommands.cs:45.
Framework.RunOnTick(() =>
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
SaveConfig();
if (notify)
Util.WrapperUtil.AddNotification(
string.Format(Resources.HellionStrings.Retention_Success, deleted),
Dalamud.Interface.ImGuiNotification.NotificationType.Success
);
if (deleted > 0)
{
// The retention thread is IsBackground=true so plugin
// unload can fire while a scheduled tick is still
// pending; bail before touching anything torn down.
if (_isDisposing)
return;
try
Log.Information($"Retention sweep deleted {deleted} expired messages.");
// Schedule on the next framework tick to avoid the ~194ms
// hitch from blocking with .Wait() while the frame finishes.
// The Config.Tabs enumeration in ClearAllTabs/FilterAllTabs is
// now guarded by the shared Plugin.TabsListLock (B3), so this
// tick scheduling is purely hitch-avoidance, not safety.
// Pattern reference: SimpleTweaks
// Tweaks/Chat/CaseInsensitiveCommands.cs:45.
Framework.RunOnTick(() =>
{
MessageManager.ClearAllTabs();
MessageManager.FilterAllTabs();
}
catch (Exception ex)
{
Log.Error(ex, "Retention sweep clear+refilter failed");
}
});
// The retention thread is IsBackground=true so plugin
// unload can fire while a scheduled tick is still
// pending; bail before touching anything torn down.
if (_isDisposing)
return;
try
{
MessageManager.ClearAllTabs();
MessageManager.FilterAllTabs();
}
catch (Exception ex)
{
Log.Error(ex, "Retention sweep clear+refilter failed");
}
});
}
else
{
Log.Information("Retention sweep ran, nothing expired.");
}
}
else
finally
{
Log.Information("Retention sweep ran, nothing expired.");
DbOperations.End(Util.DbOperation.RetentionSweep);
}
}
catch (Exception e)
{
Log.Error(e, "Retention sweep failed");
if (notify)
Util.WrapperUtil.AddNotification(
Resources.HellionStrings.Retention_Error,
Dalamud.Interface.ImGuiNotification.NotificationType.Error
);
}
finally
{
DbOperations.End();
_retentionSweepRunning = false;
}
})
{
IsBackground = true,
}.Start();
};
try
{
worker.Start();
return true;
}
catch (Exception e)
{
// The thread never ran, so nothing will clear the flag for us.
_retentionSweepRunning = false;
Log.Error(e, "Could not start the retention sweep thread");
return false;
}
}
// Read by the settings tab every frame so the manual button can say a run is
// in progress. The gate itself cannot answer that: it goes busy only once
// the worker reaches TryBegin, which is after Start returns.
private volatile bool _retentionSweepRunning;
internal bool RetentionSweepRunning => _retentionSweepRunning;
private void Draw()
{
// v1.9.0 B5: time the whole handler (style + font prologue included).