perf(inventory): debounce LiveInventoryTracker update firehose

DECAL fires ChangeObject constantly for tracked items (mana-burn ticks,
appraisal/ID data, stack tweaks); the update branch previously sent a full
inventory_delta per event — a firehose across all characters that saturated
the backend event loop (single worker pegged ~94% CPU, /live polls lagging,
characters flickering in/out of the 30s online window).

Now the update branch marks the item dirty and a System.Windows.Forms.Timer
(main-thread, STA-safe) flushes the latest state of each dirty item once per
60s. Adds/removes stay immediate (rare and meaningful). Inventory view is up
to 60s stale by design — invisible on a tracking dashboard.

Built on the last-pushed base (placeholder secret, which works via the
server's SHARED_SECRET_LEGACY); the websocket_secret.txt change is parked on
branch secret-rollout for its own coordinated deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-23 20:49:05 +02:00
parent f72a555aef
commit 92a51b205c
2 changed files with 61 additions and 3 deletions

View file

@ -15,9 +15,23 @@ namespace MosswartMassacre
private readonly IPluginLogger _logger;
private readonly HashSet<int> _trackedItemIds = new HashSet<int>();
// Items whose state changed since the last flush. DECAL fires
// ChangeObject constantly for tracked items (mana-burn ticks, ID data
// arriving, stack tweaks) — previously each one sent a full payload, a
// firehose across all characters that saturated the backend. We now
// coalesce those updates and flush the LATEST state of each dirty item
// on a WinForms timer (game main thread → STA-safe for DECAL access),
// so the backend sees ~1 update per item per flush instead of many per
// second. Adds and removes stay immediate (they're rare and meaningful).
private readonly HashSet<int> _dirtyItemIds = new HashSet<int>();
private const int FlushIntervalMs = 60000; // 60s — tune here if needed
private readonly System.Windows.Forms.Timer _flushTimer;
internal LiveInventoryTracker(IPluginLogger logger)
{
_logger = logger;
_flushTimer = new System.Windows.Forms.Timer { Interval = FlushIntervalMs };
_flushTimer.Tick += FlushDirtyItems;
}
/// <summary>
@ -27,6 +41,7 @@ namespace MosswartMassacre
internal void Initialize()
{
_trackedItemIds.Clear();
_dirtyItemIds.Clear();
try
{
foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
@ -38,6 +53,7 @@ namespace MosswartMassacre
{
_logger?.Log($"[LiveInv] Error initializing: {ex.Message}");
}
_flushTimer.Start();
}
internal void OnCreateObject(object sender, CreateObjectEventArgs e)
@ -111,9 +127,11 @@ namespace MosswartMassacre
}
else
{
// Existing item changed (equip/unequip, stack change, container move)
var mwo = MyWorldObjectCreator.Create(item);
_ = WebSocket.SendInventoryDeltaAsync("update", mwo);
// Existing item changed (equip/unequip, stack change, mana
// burn, ID data, container move). Coalesce: mark the item
// dirty and let the flush timer send its latest state once,
// instead of sending a full payload on every change.
_dirtyItemIds.Add(item.Id);
}
}
catch (Exception ex)
@ -122,8 +140,48 @@ namespace MosswartMassacre
}
}
/// <summary>
/// Flush coalesced item updates: send the CURRENT state of each item
/// that changed since the last tick, one "update" per item. Runs on the
/// WinForms timer, i.e. the game's main thread, so the DECAL world-object
/// access here is STA-safe.
/// </summary>
private void FlushDirtyItems(object sender, EventArgs e)
{
if (_dirtyItemIds.Count == 0) return;
// Snapshot then clear, so changes arriving during the flush are
// captured for the next tick rather than dropped.
var ids = new List<int>(_dirtyItemIds);
_dirtyItemIds.Clear();
foreach (int id in ids)
{
try
{
// Skip items that left inventory since being marked dirty —
// the release/change-out path already sent a remove.
if (!_trackedItemIds.Contains(id)) continue;
WorldObject item = CoreManager.Current.WorldFilter[id];
if (item == null || !IsPlayerInventory(item)) continue;
var mwo = MyWorldObjectCreator.Create(item);
_ = WebSocket.SendInventoryDeltaAsync("update", mwo);
}
catch (Exception ex)
{
_logger?.Log($"[LiveInv] Error flushing item {id}: {ex.Message}");
}
}
}
internal void Cleanup()
{
// Stop the flush timer so the disposed/old instance can't keep
// firing (and can be GC'd) across logout / hot reload.
_flushTimer.Stop();
_dirtyItemIds.Clear();
_trackedItemIds.Clear();
}