Keep storage moving in the background
The standard maintainer schedules merges after segment movement and periodically cleans inactive read caches. Its controls and events let an application adapt that policy to its workload.
Run the standard maintainer
Create one maintainer alongside the tree. This complete program writes a record, requests a merge of current in-memory data, and waits for tracked background work:
using ZoneTree;
using var tree = new ZoneTreeFactory<int, string>()
.SetDataDirectory("data/maintenance-example")
.OpenOrCreate();
using var maintainer = tree.CreateMaintainer();
tree.Upsert(42, "Ada");
maintainer.EvictToDisk();
await maintainer.WaitForBackgroundThreadsAsync();
Console.WriteLine($"Read-only segments: {tree.Maintenance.ReadOnlySegmentsCount}"); For normal service operation, keep the tree and maintainer alive and let the event-driven policy schedule merges. Explicit eviction and waiting are useful at controlled maintenance points; they are not needed after every write.
Maintainer and maintenance APIs
| Surface | Responsibility |
|---|---|
tree.CreateMaintainer() | Standard merge policy, merge-thread tracking, periodic cache cleanup, and disposal coordination. Returns a caller-owned IMaintainer. |
tree.Maintenance | Segment state, counters, lifecycle events, and lower-level operations for custom policy. |
The maintainer schedules normal merges when mutable segments move forward. Bottom-segment compaction remains an explicit operation. Read LSM-tree architecture for the distinction between mutable, read-only, disk, and bottom layers.
Tune merge triggers
After the mutable segment moves forward, the default maintainer starts a normal merge when either limit is exceeded:
| Setting | Default | Trigger |
|---|---|---|
ThresholdForMergeOperationStart | 0 | Read-only record count exceeds the threshold. |
MaximumReadOnlySegmentCount | 64 | Read-only segment count exceeds the limit. |
With the default record threshold, any non-empty read-only layer can trigger a merge after movement. To collect larger batches or bound the number of queued segments:
maintainer.ThresholdForMergeOperationStart = 500_000;
maintainer.MaximumReadOnlySegmentCount = 32; Higher thresholds can reduce the frequency of small merges, but allow more records to remain in memory. Choose them alongside record size, write rate, and available memory; a record count is not a byte budget.
Request a merge and wait for it
EvictToDisk() moves the active mutable segment forward and starts a normal merge:
maintainer.EvictToDisk();
maintainer.WaitForBackgroundThreads(); StartMerge() requests a normal merge without explicitly moving the current mutable segment. WaitForBackgroundThreads() waits for the merge threads tracked by this maintainer; it does not stop application writers or create an application-wide checkpoint by itself.
For asynchronous code, use await maintainer.WaitForBackgroundThreadsAsync().
Compact bottom segments
Run a bottom merge when your policy wants to compact persistent bottom-layer segments:
maintainer.StartBottomSegmentsMerge();
maintainer.WaitForBackgroundThreads(); To request a selected range in the current bottom-segment order:
maintainer.StartBottomSegmentsMerge(fromIndex: 0, toIndex: 4);
maintainer.WaitForBackgroundThreads(); The default bounds are 0 and int.MaxValue, asking the engine to merge as much of the current bottom layer as possible. The indexes refer to segment order, not to record keys. Schedule this work around the CPU, I/O, and latency budget of your service.
Control inactive-cache cleanup
The standard maintainer starts periodic cleanup automatically:
| Setting | Default | Effect |
|---|---|---|
EnableJobForCleaningInactiveCaches | true | Enables the periodic cleanup job. |
BlockCacheLifeTime | 1 minute | Controls how long inactive decompressed disk blocks remain eligible for retention. |
InactiveBlockCacheCleanupInterval | 30 seconds | Sets the timer period when the cleanup job starts. Restart the job to apply a changed interval. |
maintainer.EnableJobForCleaningInactiveCaches = false;
maintainer.BlockCacheLifeTime = TimeSpan.FromMinutes(2);
maintainer.InactiveBlockCacheCleanupInterval = TimeSpan.FromSeconds(15);
maintainer.EnableJobForCleaningInactiveCaches = true; The cleanup interval is captured when the periodic timer is created; assigning the property alone does not reconfigure an already running timer. BlockCacheLifeTime is read on each cleanup pass.
Longer retention can help repeated reads and also retain more memory. Cleanup releases inactive block buffers and expired circular key/value cache entries. The .NET runtime may retain released memory for reuse rather than immediately returning it to the operating system.
See memory usage and read-path caching when interpreting process memory or changing the cache policy.
Observe progress and failures
Subscribe to merge completion to record the outcome:
tree.Maintenance.OnMergeOperationEnded += (_, result) =>
{
Console.WriteLine($"Merge ended: {result}");
}; | Result | Interpretation |
|---|---|
SUCCESS | The merge completed. |
NOTHING_TO_MERGE | There were no eligible read-only segments. |
ANOTHER_MERGE_IS_RUNNING | A merge of the same kind was already active. |
RETRY_READONLY_SEGMENTS_ARE_NOT_READY | Read-only segments were still preparing. |
CANCELLED_BY_USER | A cancellation request was observed. |
FAILURE | The merge failed; inspect the configured logger. |
The standard maintainer retries RETRY_READONLY_SEGMENTS_ARE_NOT_READY. After ANOTHER_MERGE_IS_RUNNING, it schedules follow-up work after the active normal merge finishes.
Useful counters on tree.Maintenance include:
| Counter | What it measures |
|---|---|
MutableSegmentRecordCount | Records in the active mutable segment. |
ReadOnlySegmentsCount | Queued read-only in-memory segments. |
ReadOnlySegmentsRecordCount | Records across those read-only segments. |
InMemoryRecordCount | Mutable and read-only records combined. |
TotalRecordCount | Physical records across memory and disk layers. |
IsMerging / IsBottomSegmentsMerging | Whether each merge kind is active. |
Physical records can include multiple versions and deletion markers. Use Count() or CountFullScan() for live-record counts, with the cost and visibility requirements of your workload in mind.
Maintainer shutdown and disposal
Stop accepting application work and finish scans before closing storage. Short-lived iterators let old segments and files be reclaimed; long-lived iterators can pin them after a merge.
Maintainer disposal waits for tracked merge threads, requests cancellation of periodic cleanup, and detaches event handlers. The wait methods join tracked merge threads; they do not join the cache-cleanup task or arbitrary application work. For a cancellation-first shutdown:
maintainer.TryCancelBackgroundThreads();
maintainer.WaitForBackgroundThreads(); Cancellation is a request. The threads still need to observe it and finish. After the wait, dispose the maintainer and then the tree. The using declaration order in the first example handles that disposal sequence.
Build a custom policy when needed
Use tree.Maintenance when integrating with your own scheduler or storage orchestrator. A direct merge returns a thread, or null if a merge of the same kind is already active:
tree.Maintenance.MoveMutableSegmentForward();
var mergeThread = tree.Maintenance.StartMergeOperation();
mergeThread?.Join(); A null return is not proof that maintenance is finished. Observe the existing work through the appropriate state and events. Directly started threads are outside the standard maintainer's thread tracking, so your custom policy must coordinate them.