feat: Adds new decay system and SkipSerialization (#2311)

## Summary

- Replaces scan-based decay checking during world saves with an event-driven timer wheel scheduler
- Removes virtual property checks from serialization hot path, achieving 6-8x faster world saves

## Changes

### DecayScheduler (new):
- Timer wheel with 12 HashSet buckets (5-min intervals) + PriorityQueue for active processing
- O(1) register/unregister vs O(n) scan of all items
- Auto start/stop when items exist/empty
- Configurable tick interval with jitter to prevent system synchronization

### Item.cs:
- Added ScheduledDecayTime computed property
- Added UpdateDecayRegistration() called from SetLastMoved(), property setters, AddItem()/RemoveItem()
- Hooks in Visible, Movable, Spawner setters and Delete()

### World.cs:
- Removed _decayQueue, EnqueueForDecay(), ProcessDecay()
- ItemPersistence.Serialize() now tight loop without virtual calls

## Performance

| Metric        | Before     | After     | Improvement |
|---------------|------------|-----------|-------------|
| Items (230K)  | 245K ticks | 34K ticks | 8x faster   |
| Mobiles (43K) | 56K ticks  | 6K ticks  | 9x faster   |
| Total         | 302K ticks | 40K ticks | 7.5x faster |

## Configuration

decay.maxItemsPerTick = 250       # Items processed per tick
decay.tickInterval = 256ms        # Base processing interval
decay.bucketInterval = 5min       # Timer wheel bucket size
decay.jitterMaxMs = 25            # ±25ms tick jitter
This commit is contained in:
Kamron Batman 2026-01-08 11:43:51 -08:00 committed by GitHub
parent c14f361de1
commit ee2cd1d18d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 426 additions and 173 deletions

View file

@ -0,0 +1,338 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2025 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: DecayScheduler.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
namespace Server.Items;
/// <summary>
/// Timer wheel-based decay scheduler with O(1) registration/unregistration.
/// Uses coarse-grained HashSet buckets for time intervals, with a PriorityQueue
/// for fine-grained processing of items due within the current window.
/// </summary>
public class DecayScheduler : Timer
{
private const int BucketCount = 12;
private static int _maxItemsPerTick;
private static TimeSpan _tickInterval;
private static TimeSpan _bucketInterval;
private static int _jitterMaxMilliseconds;
// Timer wheel buckets (HashSets for O(1) add/remove)
private static HashSet<Item>[] _buckets;
private static int _currentBucketIndex;
private static DateTime _nextBucketRotation;
// Overflow bucket (separate from regular rotation, for items with decay > total bucket span)
private static HashSet<Item> _overflowBucket;
private static TimeSpan _totalBucketSpan;
private static DateTime _nextOverflowCheck;
// Active processing queue for items due within current bucket window
private static readonly PriorityQueue<Item, DateTime> _activeQueue = new();
public static DecayScheduler Shared { get; private set; }
public static void Configure()
{
_maxItemsPerTick = ServerConfiguration.GetSetting("decay.maxItemsPerTick", 250);
_tickInterval = ServerConfiguration.GetSetting("decay.tickInterval", TimeSpan.FromMilliseconds(256));
_bucketInterval = ServerConfiguration.GetSetting("decay.bucketInterval", TimeSpan.FromMinutes(5));
_jitterMaxMilliseconds = ServerConfiguration.GetSetting("decay.jitterMaxMs", 25); // ±25ms jitter
_buckets = new HashSet<Item>[BucketCount];
for (var i = 0; i < BucketCount; i++)
{
_buckets[i] = [];
}
// Overflow bucket is separate from the timer wheel
_overflowBucket = [];
_totalBucketSpan = TimeSpan.FromTicks(_bucketInterval.Ticks * BucketCount);
var now = Core.Now;
_nextBucketRotation = now + _bucketInterval;
_nextOverflowCheck = now + _totalBucketSpan;
Shared = new DecayScheduler();
}
private DecayScheduler() : base(_tickInterval, _tickInterval)
{
}
/// <summary>
/// Checks if all decay tracking structures are empty.
/// </summary>
private static bool IsEmpty()
{
if (_activeQueue.Count > 0 || _overflowBucket.Count > 0)
{
return false;
}
for (var i = 0; i < BucketCount; i++)
{
if (_buckets[i].Count > 0)
{
return false;
}
}
return true;
}
/// <summary>
/// Registers an item for decay tracking. Call after item becomes decay-eligible.
/// </summary>
public static void Register(Item item)
{
if (item?.Deleted != false || !item.CanDecay())
{
return;
}
// Start timer if not running
Shared.Start();
var decayTime = item.ScheduledDecayTime;
var timeUntilDecay = decayTime - Core.Now;
// If already due or due very soon, add directly to active queue
if (timeUntilDecay <= _bucketInterval)
{
_activeQueue.Enqueue(item, decayTime);
}
// If beyond the total bucket span, add to overflow bucket
else if (timeUntilDecay > _totalBucketSpan)
{
_overflowBucket.Add(item);
}
else
{
// Calculate bucket index relative to current position
var bucketOffset = GetBucketOffset(timeUntilDecay);
var absoluteIndex = (_currentBucketIndex + bucketOffset) % BucketCount;
_buckets[absoluteIndex].Add(item);
}
}
/// <summary>
/// Unregisters an item from decay tracking. Call when item is picked up, deleted, or otherwise ineligible.
/// </summary>
public static void Unregister(Item item)
{
if (item?.Deleted != false)
{
return;
}
// Check overflow bucket first
if (_overflowBucket.Remove(item))
{
return;
}
// Check regular buckets
for (var i = 0; i < BucketCount; i++)
{
if (_buckets[i].Remove(item))
{
return;
}
}
_activeQueue.Remove(item, out _, out _);
}
/// <summary>
/// Gets the bucket offset for an item based on time until decay.
/// Returns a value from 0 to BucketCount - 1, representing how many buckets ahead of current.
/// </summary>
private static int GetBucketOffset(TimeSpan timeUntilDecay)
{
// Items due within _bucketInterval go to active queue, so offset 0 means _bucketInterval to 2*_bucketInterval
var bucketOffset = (int)(timeUntilDecay.Ticks / _bucketInterval.Ticks) - 1;
// Clamp to valid range within regular buckets
return Math.Clamp(bucketOffset, 0, BucketCount - 1);
}
protected override void OnTick()
{
var now = Core.Now;
// Process items from active queue first (smaller queue = faster log(n) operations)
ProcessActiveQueue(now);
// Then check if it's time to rotate buckets (adds items for next tick)
if (now >= _nextBucketRotation)
{
RotateBuckets(now);
}
// Check overflow bucket on its own schedule (when items might enter the regular window)
if (now >= _nextOverflowCheck)
{
ProcessOverflow(now);
}
// Stop timer if nothing left to track
if (IsEmpty())
{
Stop();
return;
}
// Apply jitter for next tick to prevent synchronization with other systems
if (_jitterMaxMilliseconds > 0)
{
var jitter = Utility.Random(-_jitterMaxMilliseconds, _jitterMaxMilliseconds * 2 + 1);
Interval = _tickInterval + TimeSpan.FromMilliseconds(jitter);
}
}
/// <summary>
/// Rotates the timer wheel, moving the next bucket's contents into the active queue.
/// </summary>
private static void RotateBuckets(DateTime now)
{
_nextBucketRotation = now + _bucketInterval;
// Get the next bucket to process
var bucket = _buckets[_currentBucketIndex];
// Move all items from this bucket into the active queue (with validation)
foreach (var item in bucket)
{
if (item.Deleted || !item.CanDecay())
{
continue; // Lazy cleanup - item was picked up or deleted
}
var decayTime = item.ScheduledDecayTime;
var timeUntilDecay = decayTime - now;
if (timeUntilDecay > _bucketInterval)
{
// Item was moved (SetLastMoved called) - re-bucket or move to overflow
if (timeUntilDecay > _totalBucketSpan)
{
// Extended beyond total span - move to overflow
_overflowBucket.Add(item);
}
else
{
// Re-bucket within regular buckets
var bucketOffset = GetBucketOffset(timeUntilDecay);
var actualIndex = (_currentBucketIndex + bucketOffset) % BucketCount;
if (actualIndex != _currentBucketIndex)
{
_buckets[actualIndex].Add(item);
}
else
{
// Still maps to current bucket - add to active queue
_activeQueue.Enqueue(item, decayTime);
}
}
continue;
}
// Due within next window - add to active queue
_activeQueue.Enqueue(item, decayTime);
}
// Clear the processed bucket
bucket.Clear();
// Advance to next bucket
_currentBucketIndex = (_currentBucketIndex + 1) % BucketCount;
}
/// <summary>
/// Processes the overflow bucket, moving items that are now within the regular bucket window.
/// </summary>
private static void ProcessOverflow(DateTime now)
{
_nextOverflowCheck = now + _totalBucketSpan;
// Move items that are now within the regular bucket span
_overflowBucket.RemoveWhere(item =>
{
if (item.Deleted || !item.CanDecay())
{
return true; // Remove invalid items
}
var timeUntilDecay = item.ScheduledDecayTime - now;
if (timeUntilDecay > _totalBucketSpan)
{
return false; // Keep in overflow
}
// Now within regular bucket range - move to appropriate bucket
if (timeUntilDecay <= _bucketInterval)
{
_activeQueue.Enqueue(item, item.ScheduledDecayTime);
}
else
{
var bucketOffset = GetBucketOffset(timeUntilDecay);
var actualIndex = (_currentBucketIndex + bucketOffset) % BucketCount;
_buckets[actualIndex].Add(item);
}
return true; // Remove from overflow
});
}
/// <summary>
/// Processes items from the active queue that are due for decay.
/// </summary>
private static void ProcessActiveQueue(DateTime now)
{
var processed = 0;
while (_activeQueue.TryPeek(out var item, out var scheduledTime) && processed < _maxItemsPerTick)
{
// Not yet due - stop processing
if (scheduledTime > now)
{
break;
}
processed++;
_activeQueue.Dequeue();
if (item.Deleted || !item.CanDecay())
{
continue;
}
if (item.ScheduledDecayTime > now)
{
Register(item);
}
else if (item.OnDecay())
{
item.Delete();
}
}
}
}

View file

@ -368,6 +368,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
}
Delta(ItemDelta.Update);
UpdateDecayRegistration();
}
}
}
@ -383,6 +384,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
SetFlag(ImplFlag.Movable, value);
Delta(ItemDelta.Update);
UpdateDecayRegistration();
}
}
}
@ -1436,6 +1438,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
}
OnDelete();
DecayScheduler.Unregister(this);
var items = LookupItems();
@ -1478,6 +1481,8 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
ClearProperties();
}
public virtual bool SkipSerialization => false;
[IgnoreDupe]
public ISpawner Spawner
{
@ -1485,6 +1490,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
set
{
var info = AcquireCompactInfo();
var oldValue = info.m_Spawner;
info.m_Spawner = value;
@ -1492,6 +1498,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
{
VerifyCompactInfo();
}
if (oldValue != value)
{
UpdateDecayRegistration();
}
}
}
@ -2268,9 +2279,22 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public virtual bool OnDecay() =>
CanDecay() && Region.Find(Location, Map).OnDecay(this);
public DateTime ScheduledDecayTime => LastMoved + DecayTime;
public void UpdateDecayRegistration()
{
DecayScheduler.Unregister(this);
if (CanDecay())
{
DecayScheduler.Register(this);
}
}
public void SetLastMoved()
{
LastMoved = Core.Now;
UpdateDecayRegistration();
}
public virtual bool CanStackWith(Item dropped) =>
@ -3205,6 +3229,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
}
item.Delta(ItemDelta.Update);
item.UpdateDecayRegistration();
item.OnAdded(this);
OnItemAdded(item);
@ -3374,6 +3399,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
}
item.Parent = null;
item.UpdateDecayRegistration();
item.OnRemoved(this);
OnItemRemoved(item);

View file

@ -47,6 +47,7 @@ public sealed partial class VirtualCheck : Item
Movable = false;
}
public override bool SkipSerialization => true;
public override bool IsVirtualItem => true;
public override bool DisplayWeight => false;
public override bool DisplayLootType => false;
@ -158,10 +159,4 @@ public sealed partial class VirtualCheck : Item
Editor = null;
}
}
[AfterDeserialization(false)]
private void AfterDeserialization()
{
Delete();
}
}

View file

@ -2458,6 +2458,8 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
m_PropertyList = null;
}
public virtual bool SkipSerialization => false;
[CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)]
public Map Map
{

View file

@ -98,10 +98,19 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
}
idx.Write(3); // Version
idx.Write(EntitiesBySerial.Values.Count);
var countPosition = idx.Position;
idx.Write(0);
var entityCount = EntitiesBySerial.Count;
foreach (var e in EntitiesBySerial.Values)
{
if (e is Item { SkipSerialization: true } or Mobile { SkipSerialization: true })
{
entityCount--;
continue;
}
var thread = e.SerializedThread;
var heapStart = e.SerializedPosition;
var heapLength = e.SerializedLength;
@ -130,6 +139,11 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
binPosition += heapLength;
}
var currentPosition = idx.Position;
idx.Seek(countPosition, SeekOrigin.Begin);
idx.Write(entityCount);
idx.Seek(currentPosition, SeekOrigin.Begin);
}
public override void Serialize()

View file

@ -47,7 +47,6 @@ public static class World
private static int _threadId;
internal static SerializationThreadWorker[] _threadWorkers;
private static readonly ManualResetEvent _diskWriteHandle = new(true);
private static readonly ConcurrentQueue<Item> _decayQueue = new();
private static string _tempSavePath; // Path to the temporary folder for the save
@ -109,18 +108,6 @@ public static class World
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void WaitForWriteCompletion() => _diskWriteHandle.WaitOne();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void EnqueueForDecay(Item item)
{
if (WorldState != WorldState.Saving)
{
logger.Warning("Attempting to queue {Item} for decay but the world is not saving", item);
return;
}
_decayQueue.Enqueue(item);
}
public static void Broadcast(int hue, bool ascii, string text)
{
var length = OutgoingMessagePackets.GetMaxMessageLength(text);
@ -216,20 +203,6 @@ public static class World
}
}
private static void ProcessDecay()
{
while (_decayQueue.TryDequeue(out var item))
{
if (item.OnDecay())
{
// TODO: Add Logging
item.Delete();
}
}
}
private static DateTime _serializationStart;
/**
* Duplicates can be weeded out asynchronously while flushing
* If performance becomes a problem, we need to build a dual mode concurrent array.
@ -322,8 +295,6 @@ public static class World
try
{
_serializationStart = Core.Now;
if (string.IsNullOrEmpty(snapshotPath))
{
throw new ArgumentException("Snapshot path cannot be null or empty", nameof(snapshotPath));
@ -543,28 +514,9 @@ public static class World
}
item.ClearProperties();
item.UpdateDecayRegistration();
}
}
public override void Serialize()
{
foreach (var item in EntitiesBySerial.Values)
{
if (item.CanDecay() && item.LastMoved + item.DecayTime <= _serializationStart)
{
EnqueueForDecay(item);
}
PushToCache(item);
}
}
public override void PostWorldSave()
{
ProcessDecay(); // Run this before the safety queue
base.PostWorldSave();
}
}
private class MobilePersistence : GenericEntityPersistence<Mobile>