TemporalCollections is a personal open-source project I created to address a recurring challenge I encountered while building distributed systems: the lack of ready-to-use, time-aware data structures that are both thread-safe and optimized for time-based querying and pruning. It’s a .NET library that extends familiar collections, queues, stacks, sets, dictionaries, and more with native temporal semantics. Every item is automatically timestamped on insertion, enabling:

  • Fast time-range queries
  • Deterministic aging and pruning
  • Accurate temporal analytics

All of this comes with a unified query API and built-in thread-safety, making it easy to reason about data in time-sensitive applications. TemporalCollections is ideal for scenarios like event streaming, sliding-window analytics, telemetry buffers, rate limiting, session tracking, and caches with expiry any situation where time is a first-class concern.

👉 GitHub repository

Why Temporal Collections?

Time is a first-class dimension in many systems:

  • Event streams & observability: ingest items at high throughput and answer questions like “what happened in the last N seconds/minutes?”
  • Sliding-window analytics: compute rolling aggregates (counts, percentiles) on recent data only.
  • Caches & sessions: expire stale entries or prune by age.
  • Temporal state tracking: maintain the evolution of values over time (latest, earliest, before/after a point).

While you could bolt timestamps onto standard collections, you would still need to solve ordering, race-free timestamp assignment, range queries, pruning, and concurrency consistently across multiple data structures. TemporalCollections addresses these concerns out-of-the-box with a monotonic timestamp guarantee and a common query surface.

Core Design Principles

  • Temporal semantics: items carry precise insertion timestamps.
  • Thread-safety: operations are safe in multi-threaded environments.
  • Time-based querying: efficient retrieval by time windows.
  • Efficient cleanup: prune older items without long global locks.

Monotonic Timestamp

Temporal collections only make sense if time behaves. In practice, though, system clocks don’t always cooperate: multiple calls to UtcNow within the same tick can return identical values; NTP can move the clock backwards; and highly concurrent code can interleave operations so tightly that two insertions appear to occur at the same instant. If timestamps aren’t strictly ordered, time-window queries become flaky (GetInRange may miss or double count items on boundaries) and age-based pruning (RemoveOlderThan) isn’t deterministic. To keep temporal behavior predictable, the library assigns a monotonic timestamp to every insertion: each generated value is guaranteed to be strictly greater than the one before it within the same process. If the clock doesn’t advance between two reads, we simply step forward by one tick and move on.

Deterministic boundary rules

  • GetInRange(from, to) is inclusive on both ends.
  • RemoveOlderThan(cutoff) removes Timestamp < cutoff (keeps >= cutoff).
  • GetBefore(time) is strictly <; GetAfter(time) is strictly >.
  • GetLatest() / GetEarliest() return extremes or null when empty.

These rules make window math predictable and prevent off-by-one bugs.

Snapshot semantics

Enumerations return a stable snapshot at call time, preserving determinism under concurrency.

The Core Abstraction: TemporalItem<T>

All collections store TemporalItem<T>, a lightweight wrapper that pairs an immutable value with a timestamp (DateTimeOffset) representing the insertion moment. Timestamps are strictly increasing even under bursty or concurrent insertions: if UtcNow would produce a non-increasing value (precision limits / clock granularity), the library atomically increments by a tick to maintain order and uniqueness. This yields deterministic chronology without races across threads.

A Unified Temporal Query Surface: ITimeQueryable<T>

Every structure implements ITimeQueryable<T>, exposing consistent operations:

  • GetInRange(from, to): enumerate items in an inclusive time window.
  • RemoveOlderThan(cutoff): age/prune items strictly older than cutoff.
  • CountInRange(from, to): count items in a window.
  • GetTimeSpan(): time span covered by the collection (latest−earliest).
  • RemoveRange(from, to): delete items in a window.
  • GetLatest() / GetEarliest(): fast access to extremes.
  • GetBefore(time) / GetAfter(time): query by relative time.
  • CountSince(from): rolling counts.
  • GetNearest(time): nearest neighbor by timestamp.

This interface makes code collection agnostic, you can prototype with a queue and later swap to a sorted structure or an interval tree without rewriting queries.

Provided Data Structures

  • TemporalQueue
    • ✅ Use when you need a thread-safe FIFO queue with time-based retrieval and cleanup.
    • ❌ Avoid if you need priority ordering or random access.
  • TemporalStack
    • ✅ Use when you want a thread-safe LIFO stack with timestamp tracking and time-range queries.
    • ❌ Avoid if you require fast arbitrary removal or frequent sorting by timestamp.
  • TemporalSet
    • ✅ Use for unique timestamped items with efficient membership checks and time-based removal.
    • ❌ Avoid if you need ordering or priority queues.
  • TemporalSlidingWindowSet
    • ✅ Use when you need to automatically retain only recent items within a fixed time window.
    • ❌ Avoid if your window size is highly dynamic or if you need sorted access.
  • TemporalSortedList
    • ✅ Use for a sorted-by-timestamp collection with efficient binary-search range queries.
    • ❌ Avoid if insertion frequency is very high (O(n) inserts).
  • TemporalPriorityQueue
    • ✅ Use when you need priority-based ordering with timestamp-aware dequeueing.
    • ❌ Avoid if you only need FIFO/LIFO semantics without priorities.
  • TemporalIntervalTree
    • ✅ Use for efficient interval overlap queries and session windows.
    • ❌ Avoid if your data are single points rather than intervals.
  • TemporalDictionary<TKey, TValue>
    • ✅ Use for key-based access combined with global time-range queries.
    • ❌ Avoid if you require a fully ordered view or range queries strictly sorted by timestamp.
  • TemporalCircularBuffer
    • ✅ Use for a fixed-size ring buffer that overwrites the oldest items.
    • ❌ Avoid if you need unbounded storage or complex queries.

🚀 Getting Started with TemporalCollections

This section shows how to install and use TemporalCollections in your .NET projects with simple examples.

Installation

dotnet add package TemporalCollections

Basic usage

TemporalQueue

using System;
using System.Linq;
using TemporalCollections.Collections;

var queue = new TemporalQueue<string>();

// Enqueue items (timestamps are assigned automatically)
queue.Enqueue("event-1");
queue.Enqueue("event-2");

// Peek oldest (does not remove)
var oldest = queue.Peek();
Console.WriteLine($"Oldest: {oldest.Value} @ {oldest.Timestamp}");

// Dequeue oldest (removes)
var dequeued = queue.Dequeue();
Console.WriteLine($"Dequeued: {dequeued.Value} @ {dequeued.Timestamp}");

// Query by time range (inclusive)
var from = DateTime.UtcNow.AddMinutes(-5);
var to   = DateTime.UtcNow;
var inRange = queue.GetInRange(from, to);
foreach (var item in inRange)
{
    Console.WriteLine($"In range: {item.Value} @ {item.Timestamp}");
}

TemporalSet

using System;
using TemporalCollections.Collections;

var set = new TemporalSet<int>();

set.Add(1);
set.Add(2);
set.Add(2);

Console.WriteLine(set.Contains(1));

// Remove older than a cutoff
var cutoff = DateTime.UtcNow.AddMinutes(-10);
set.RemoveOlderThan(cutoff);

// Snapshot of all items ordered by timestamp
var items = set.GetItems();

TemporalDictionary<TKey, TValue>

using System;
using System.Linq;
using TemporalCollections.Collections;

var dict = new TemporalDictionary<string, string>();

dict.Add("user:1", "login");
dict.Add("user:2", "logout");
dict.Add("user:1", "refresh");

// Range query across all keys
var from = DateTime.UtcNow.AddMinutes(-1);
var to   = DateTime.UtcNow.AddMinutes(1);
var all = dict.GetInRange(from, to);

// Range query for a specific key
var user1 = dict.GetInRange("user:1", from, to);

// Compute span covered by all events
var span = dict.GetTimeSpan();
Console.WriteLine($"Span: {span}");

// Remove a time window across all keys
dict.RemoveRange(from, to);

TemporalStack

using System;
using System.Linq;
using TemporalCollections.Collections;

var stack = new TemporalStack<string>();

// Push (timestamps assigned automatically, monotonic UTC)
stack.Push("first");
stack.Push("second");

// Peek last pushed (does not remove)
var top = stack.Peek();
Console.WriteLine($"Top: {top.Value} @ {top.Timestamp}");

// Pop last pushed (removes)
var popped = stack.Pop();
Console.WriteLine($"Popped: {popped.Value}");

// Time range query (inclusive)
var from = DateTime.UtcNow.AddMinutes(-5);
var to   = DateTime.UtcNow;
var items = stack.GetInRange(from, to).OrderBy(i => i.Timestamp);

// Remove older than cutoff
var cutoff = DateTime.UtcNow.AddMinutes(-10);
stack.RemoveOlderThan(cutoff);

TemporalSlidingWindowSet

using System;
using System.Linq;
using TemporalCollections.Collections;

var window = TimeSpan.FromMinutes(10);
var swSet = new TemporalSlidingWindowSet<string>(window);

// Add unique items (insertion timestamp recorded)
swSet.Add("A");
swSet.Add("B");

// Periodically expire items older than the window
swSet.RemoveExpired();

// Snapshot (ordered by timestamp)
var snapshot = swSet.GetItems().ToList();

// Query by time range
var from = DateTime.UtcNow.AddMinutes(-5);
var to   = DateTime.UtcNow;
var inRange = swSet.GetInRange(from, to);

// Manual cleanup by cutoff (if needed)
swSet.RemoveOlderThan(DateTime.UtcNow.AddMinutes(-30));

TemporalSortedList

using System;
using System.Linq;
using TemporalCollections.Collections;

var list = new TemporalSortedList<int>();

// Add items (kept sorted by timestamp internally)
list.Add(10);
list.Add(20);
list.Add(30);

// Fast range query via binary search (inclusive)
var from = DateTime.UtcNow.AddSeconds(-30);
var to   = DateTime.UtcNow;
var inRange = list.GetInRange(from, to);

// Before / After helpers
var before = list.GetBefore(DateTime.UtcNow);
var after  = list.GetAfter(DateTime.UtcNow.AddSeconds(-5));

// Housekeeping
list.RemoveOlderThan(DateTime.UtcNow.AddMinutes(-1));
Console.WriteLine($"Span: {list.GetTimeSpan()}");

TemporalPriorityQueue<TPriority, TValue>

using System;
using System.Linq;
using TemporalCollections.Collections;

var pq = new TemporalPriorityQueue<int, string>();

// Enqueue with explicit priority (lower number = higher priority)
pq.Enqueue("high", priority: 1);
pq.Enqueue("low",  priority: 10);

// TryPeek (does not remove)
if (pq.TryPeek(out var next))
{
    Console.WriteLine($"Peek: {next}");
}

// TryDequeue (removes highest-priority; stable by insertion time)
while (pq.TryDequeue(out var val))
{
    Console.WriteLine($"Dequeued: {val}");
}

// Time-based queries are also available
var from = DateTime.UtcNow.AddMinutes(-5);
var to   = DateTime.UtcNow;
var items = pq.GetInRange(from, to);

Console.WriteLine($"Count in range: {pq.CountInRange(from, to)}");

TemporalCircularBuffer

using System;
using System.Linq;
using TemporalCollections.Collections;

// Fixed-capacity ring buffer; overwrites oldest when full
var buf = new TemporalCircularBuffer<string>(capacity: 3);

buf.Add("A");
buf.Add("B");
buf.Add("C");
buf.Add("D"); // Overwrites "A"

// Snapshot (oldest -> newest)
var snapshot = buf.GetSnapshot();
foreach (var it in snapshot)
{
    Console.WriteLine($"{it.Value} @ {it.Timestamp}");
}

// Range queries
var from = DateTime.UtcNow.AddMinutes(-5);
var to   = DateTime.UtcNow;
var inRange = buf.GetInRange(from, to);

// Remove a time window
buf.RemoveRange(from, to);

// Cleanup by cutoff (keeps >= cutoff)
buf.RemoveOlderThan(DateTime.UtcNow.AddMinutes(-1));

TemporalIntervalTree

using System;
using System.Linq;
using TemporalCollections.Collections;

var tree = new TemporalIntervalTree<string>();

var now = DateTime.UtcNow;
tree.Insert(now, now.AddMinutes(10), "session:A");
tree.Insert(now.AddMinutes(5), now.AddMinutes(15), "session:B");

// Overlap query (values only)
var overlapValues = tree.Query(now.AddMinutes(7), now.AddMinutes(12));
// Overlap query (with timestamps = interval starts)
var overlapItems  = tree.GetInRange(now.AddMinutes(7), now.AddMinutes(12));

Console.WriteLine($"Overlaps: {string.Join(", ", overlapValues)}");

// Remove intervals that ended before a cutoff
tree.RemoveOlderThan(now.AddMinutes(9));

Threading Model & Big-O Cheatsheet

All collections are thread-safe. Locking granularity and common operations (amortized):

Collection Locking Add/Push Range Query RemoveOlderThan
TemporalQueue single lock around a queue snapshot O(1) O(n) O(k) from head
TemporalStack single lock; drain & rebuild for window ops O(1) O(n) O(n)
TemporalSet lock-free dict + per-bucket ops O(1) avg O(n) O(n)
TemporalSortedList single lock; binary search for ranges O(n) insert O(log n + m) O(k)
TemporalPriorityQueue single lock; SortedSet by (priority,timestamp) O(log n) O(n) O(n)
TemporalIntervalTree single lock; interval overlap pruning O(log n) avg O(log n + m) O(n)
TemporalDictionary concurrent dict + per-list lock O(1) avg O(n) O(n)
TemporalCircularBuffer single lock; ring overwrite O(1) O(n) O(n)

n = items, m = matches, k = removed.

Benchmark Results

Measured with BenchmarkDotNet, the results paint a consistent picture:

  • Insert-heavy pipelines with periodic age-off
    TemporalQueue and TemporalCircularBuffer deliver the lowest median insert times (constant-time appends) and predictable pruning
    (head-first for the queue, overwrite for the ring).

  • Frequent, wide time-window queries over large datasets
    TemporalSortedList (binary-search boundaries) and TemporalIntervalTree (overlap index) offer the best query latency,
    at the cost of more expensive inserts—especially for the sorted list.

  • Middle ground
    TemporalSet and TemporalSlidingWindowSet show good insertion behavior and simple maintenance,
    but range scans are linear compared to indexed structures.

  • Priority-aware processing
    TemporalPriorityQueue optimizes for priority-based dequeue, so time-range scans and pruning are comparatively slower.

  • Per-key histories + global time queries
    TemporalDictionary<TKey,TValue> is a balanced option when you need per-key histories together with global time queries,
    while TemporalStack mirrors the queue on inserts but pays linear costs on range queries and pruning.

For exact median timings, environment details, and methodology, see the full report:

👉 Full Benchmark report

Conclusion

TemporalCollections offers a pragmatic, production-minded approach to managing time-aware data in .NET: you get consistent timestamps, a unified query API, and a portfolio of structures optimized for different temporal needs. Start simple with a queue or sliding window set; when your workload demands it, switch to a sorted or interval-based structure, without changing how you query by time.


<
Previous Post
The Pragmatic Power of Modular Monoliths in .NET
>
Next Post
Building Resilient Distributed Systems with MassTransit in .NET