From 1bd875d75e5855ccc83f6459c7f3ce13f4ef8322 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 23 Nov 2025 19:39:16 +0100 Subject: [PATCH 01/34] feat(benchmarks): update Program to run all benchmarks --- src/TemporalCollections.PerformanceTests/Program.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/TemporalCollections.PerformanceTests/Program.cs b/src/TemporalCollections.PerformanceTests/Program.cs index 97c9c32..4cd31a2 100644 --- a/src/TemporalCollections.PerformanceTests/Program.cs +++ b/src/TemporalCollections.PerformanceTests/Program.cs @@ -37,6 +37,8 @@ static void Main(string[] args) typeof(TemporalStackBenchmarks), typeof(TemporalSlidingWindowSetBenchmarks), typeof(TemporalCircularBufferBenchmarks), + typeof(TemporalMultimapBenchmarks), + typeof(TemporalSegmentedArrayBenchmarks), }; var allSummaries = new List(); From 449429ca9b22a6cdd1e430cdcc13571aa6053f4f Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 23 Nov 2025 22:45:16 +0100 Subject: [PATCH 02/34] fix(temporal-dictionary): optimize Add method using monotonic timestamps for O(1) append --- .../Collections/TemporalDictionary.cs | 14 +++++++++++--- src/TemporalCollections/TemporalCollections.csproj | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/TemporalCollections/Collections/TemporalDictionary.cs b/src/TemporalCollections/Collections/TemporalDictionary.cs index 36f8f61..db2948a 100644 --- a/src/TemporalCollections/Collections/TemporalDictionary.cs +++ b/src/TemporalCollections/Collections/TemporalDictionary.cs @@ -29,11 +29,19 @@ public void Add(TKey key, TValue value) { var temporalItem = TemporalItem.Create(value); var list = _dict.GetOrAdd(key, _ => []); + lock (list) { - int idx = list.BinarySearch(temporalItem, TimestampOnlyComparer); - if (idx < 0) idx = ~idx; - list.Insert(idx, temporalItem); + // Because timestamps are strictly increasing across all TemporalItem + // instances created via TemporalItem.Create, and we never insert items + // with arbitrary timestamps, we know that: + // + // - For each key, the sequence of timestamps in "list" is strictly increasing. + // - The new "temporalItem" always has the largest (most recent) timestamp. + // + // Therefore, we can safely append to the end of the list while preserving + // the sorted-by-timestamp invariant. No BinarySearch + Insert is needed. + list.Add(temporalItem); } } diff --git a/src/TemporalCollections/TemporalCollections.csproj b/src/TemporalCollections/TemporalCollections.csproj index 52f60c3..35065b3 100644 --- a/src/TemporalCollections/TemporalCollections.csproj +++ b/src/TemporalCollections/TemporalCollections.csproj @@ -5,7 +5,7 @@ enable enable True - 1.2.0 + 1.2.1 TemporalCollections Francesco Del Re Thread-safe .NET collections with built-in, monotonic timestamps and a unified API for fast range queries, pruning, and temporal analytics. From bdf6d772e0b22ebf96423572e0ea48122d89c4a7 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 8 Dec 2025 22:49:15 +0100 Subject: [PATCH 03/34] chore(deps): update project dependencies --- .../TemporalCollections.PerformanceTests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj b/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj index 2a3d0df..3c0aa9a 100644 --- a/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj +++ b/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj @@ -9,7 +9,7 @@ - + From 5dd9aa5ff1e87b4b61d2f5fe9231330644b4aeb3 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 26 Jan 2026 00:37:10 +0100 Subject: [PATCH 04/34] test: add more coverage for TemporalSortedList --- .../Collections/TemporalSortedListTests.cs | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/src/TemporalCollections.Tests/Collections/TemporalSortedListTests.cs b/src/TemporalCollections.Tests/Collections/TemporalSortedListTests.cs index 8313dc6..8e81e7e 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalSortedListTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalSortedListTests.cs @@ -192,5 +192,291 @@ public void TemporalSortedList_GetNearest_WorksAndTiesPreferLater() Assert.Equal(3, nearCeil!.Value); } } + + [Fact] + public void GetInRange_WhenFromGreaterThanTo_SwapsAndReturnsSameResults() + { + var list = new TemporalSortedList(); + list.Add("a"); + list.Add("b"); + list.Add("c"); + + var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + Assert.Equal(3, all.Count); + + var min = all.First().Timestamp.UtcDateTime; + var max = all.Last().Timestamp.UtcDateTime; + + var normal = list.GetInRange(min, max).Select(i => i.Value).ToList(); + var swapped = list.GetInRange(max, min).Select(i => i.Value).ToList(); + + Assert.Equal(normal, swapped); + } + + [Fact] + public void GetInRange_OnEmptyList_ReturnsEmpty() + { + var list = new TemporalSortedList(); + var items = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + Assert.Empty(items); + } + + [Fact] + public void CountInRange_OnEmptyList_ReturnsZero() + { + var list = new TemporalSortedList(); + Assert.Equal(0, list.CountInRange(DateTime.MinValue, DateTime.MaxValue)); + } + + [Fact] + public void GetLatest_And_GetEarliest_OnEmptyList_ReturnNull() + { + var list = new TemporalSortedList(); + Assert.Null(list.GetLatest()); + Assert.Null(list.GetEarliest()); + } + + [Fact] + public void GetLatest_And_GetEarliest_ReturnCorrectItems() + { + var list = new TemporalSortedList(); + list.Add("first"); + list.Add("second"); + list.Add("third"); + + var earliest = list.GetEarliest(); + var latest = list.GetLatest(); + + Assert.NotNull(earliest); + Assert.NotNull(latest); + + Assert.Equal("first", earliest!.Value); + Assert.Equal("third", latest!.Value); + + Assert.True(earliest.Timestamp <= latest.Timestamp); + } + + [Fact] + public void GetBefore_IsStrictlyBefore_Cutoff() + { + var list = new TemporalSortedList(); + list.Add(1); + list.Add(2); + list.Add(3); + + var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var t2 = all.Single(i => i.Value == 2).Timestamp.UtcDateTime; + + var before = list.GetBefore(t2).Select(i => i.Value).ToList(); + + Assert.Contains(1, before); + Assert.DoesNotContain(2, before); // Strictly before + Assert.DoesNotContain(3, before); + } + + [Fact] + public void GetAfter_IsStrictlyAfter_Cutoff() + { + var list = new TemporalSortedList(); + list.Add(1); + list.Add(2); + list.Add(3); + + var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var t2 = all.Single(i => i.Value == 2).Timestamp.UtcDateTime; + + var after = list.GetAfter(t2).Select(i => i.Value).ToList(); + + Assert.Contains(3, after); + Assert.DoesNotContain(2, after); // Strictly after + Assert.DoesNotContain(1, after); + } + + [Fact] + public void RemoveRange_RemovesInclusiveBounds() + { + var list = new TemporalSortedList(); + list.Add(1); + list.Add(2); + list.Add(3); + list.Add(4); + + var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var t2 = all.Single(i => i.Value == 2).Timestamp.UtcDateTime; + var t3 = all.Single(i => i.Value == 3).Timestamp.UtcDateTime; + + // Remove [2,3] inclusive + list.RemoveRange(t2, t3); + + var remaining = list.GetInRange(DateTime.MinValue, DateTime.MaxValue) + .Select(i => i.Value) + .ToList(); + + Assert.Equal(new[] { 1, 4 }, remaining); + } + + [Fact] + public void RemoveRange_WhenOutsideBounds_DoesNothing() + { + var list = new TemporalSortedList(); + list.Add(1); + list.Add(2); + list.Add(3); + + var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var first = all.First().Timestamp.UtcDateTime; + var last = all.Last().Timestamp.UtcDateTime; + + // A range strictly before the first item + var beforeFrom = first.AddSeconds(-10); + var beforeTo = first.AddSeconds(-1); + + list.RemoveRange(beforeFrom, beforeTo); + + Assert.Equal(3, list.Count); + + // A range strictly after the last item + var afterFrom = last.AddSeconds(1); + var afterTo = last.AddSeconds(10); + + list.RemoveRange(afterFrom, afterTo); + + Assert.Equal(3, list.Count); + } + + [Fact] + public void Clear_RemovesAllItems() + { + var list = new TemporalSortedList(); + list.Add("a"); + list.Add("b"); + + Assert.True(list.Count > 0); + + list.Clear(); + + Assert.Equal(0, list.Count); + Assert.Empty(list.GetInRange(DateTime.MinValue, DateTime.MaxValue)); + Assert.Null(list.GetLatest()); + Assert.Null(list.GetEarliest()); + } + + [Fact] + public void CountInRange_MatchesGetInRangeCount() + { + var list = new TemporalSortedList(); + list.Add(10); + list.Add(20); + list.Add(30); + + var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var t1 = all[0].Timestamp.UtcDateTime; + var t3 = all[2].Timestamp.UtcDateTime; + + var items = list.GetInRange(t1, t3).ToList(); + var count = list.CountInRange(t1, t3); + + Assert.Equal(items.Count, count); + Assert.Equal(3, count); + } + + [Fact] + public void GetTimeSpan_ReturnsZero_ForLessThanTwoItems() + { + var list = new TemporalSortedList(); + Assert.Equal(TimeSpan.Zero, list.GetTimeSpan()); + + list.Add(1); + Assert.Equal(TimeSpan.Zero, list.GetTimeSpan()); + } + + [Fact] + public void GetTimeSpan_IsNonNegative_AndConsistentWithEarliestLatest() + { + var list = new TemporalSortedList(); + list.Add(1); + list.Add(2); + list.Add(3); + + var span = list.GetTimeSpan(); + Assert.True(span >= TimeSpan.Zero); + + var earliest = list.GetEarliest()!; + var latest = list.GetLatest()!; + var expected = latest.Timestamp - earliest.Timestamp; + + // Per implementation, negative spans are clamped to zero; here it must be non-negative. + Assert.Equal(expected, span); + } + + [Fact] + public void GetNearest_OnEmptyList_ReturnsNull() + { + var list = new TemporalSortedList(); + Assert.Null(list.GetNearest(DateTime.UtcNow)); + } + + [Fact] + public void GetNearest_BeforeFirst_ReturnsFirst_AfterLast_ReturnsLast() + { + var list = new TemporalSortedList(); + list.Add(1); + list.Add(2); + list.Add(3); + + var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var first = all.First(); + var last = all.Last(); + + var before = list.GetNearest(first.Timestamp.UtcDateTime.AddTicks(-10)); + var after = list.GetNearest(last.Timestamp.UtcDateTime.AddTicks(10)); + + Assert.NotNull(before); + Assert.NotNull(after); + + Assert.Equal(first.Value, before!.Value); + Assert.Equal(last.Value, after!.Value); + } + + [Fact] + public void RemoveOlderThan_IsExclusive_RemovesStrictlyOlderOnly() + { + var list = new TemporalSortedList(); + list.Add(1); + list.Add(2); + list.Add(3); + + var all = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var t2 = all.Single(i => i.Value == 2).Timestamp.UtcDateTime; + + // Remove items with ts < t2 (exclusive), so item "2" must remain. + list.RemoveOlderThan(t2); + + var remaining = list.GetInRange(DateTime.MinValue, DateTime.MaxValue) + .Select(i => i.Value) + .ToList(); + + Assert.Equal(new[] { 2, 3 }, remaining); + } + + [Fact] + public void Add_ManyItems_CountMatches_AndTimestampsAreStrictlyIncreasing() + { + var list = new TemporalSortedList(); + + for (int i = 0; i < 500; i++) + list.Add(i); + + Assert.Equal(500, list.Count); + + var items = list.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + Assert.Equal(500, items.Count); + + for (int i = 1; i < items.Count; i++) + { + // TemporalItem.Create guarantees strictly increasing UTC ticks per closed generic type. + Assert.True(items[i].Timestamp.UtcTicks > items[i - 1].Timestamp.UtcTicks); + } + } } } \ No newline at end of file From 0ba5bd41c83e6d8e5d35b51a2679aa464e921abc Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Wed, 28 Jan 2026 12:49:06 +0100 Subject: [PATCH 05/34] test(temporal-queue): add coverage for edge cases and concurrency scenarios --- .../Collections/TemporalQueueTests.cs | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) diff --git a/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs b/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs index 2a899e1..8eb55d2 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs @@ -563,5 +563,271 @@ public void TemporalQueue_GetNearest_WorksAndTiesPreferLater() Assert.Equal("C", nearCeil!.Value); } } + + [Fact] + public void GetInRange_WhenBoundsAreReversed_ShouldStillWork() + { + var q = new TemporalQueue(); + q.Enqueue(1); + q.Enqueue(2); + q.Enqueue(3); + + var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + Assert.True(all.Count >= 3); + + // Pick a middle window using actual timestamps (deterministic) + var from = all[2].Timestamp.UtcDateTime; // newer + var to = all[1].Timestamp.UtcDateTime; // older (reversed) + + var res = q.GetInRange(from, to).Select(x => x.Value).ToList(); + + // Bounds are swapped internally -> should include items 2..3? Actually window [ts2, ts3] + Assert.Contains(2, res); + Assert.Contains(3, res); + Assert.DoesNotContain(1, res); + } + + [Fact] + public void CountInRange_WhenBoundsAreReversed_ShouldMatchGetInRange() + { + var q = new TemporalQueue(); + for (int i = 0; i < 10; i++) q.Enqueue(i); + + var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var a = all[7].Timestamp.UtcDateTime; + var b = all[3].Timestamp.UtcDateTime; + + var expected = q.GetInRange(a, b).Count(); // reversed + var counted = q.CountInRange(a, b); + + Assert.Equal(expected, counted); + } + + [Fact] + public void CountSince_ShouldReturnZero_WhenCutoffAfterLatest() + { + var q = new TemporalQueue(); + q.Enqueue(1); + q.Enqueue(2); + + var latest = q.GetLatest()!; + var cutoff = latest.Timestamp.UtcDateTime.AddTicks(1); + + Assert.Equal(0, q.CountSince(cutoff)); + } + + [Fact] + public void CountSince_ShouldReturnAll_WhenCutoffBeforeEarliest() + { + var q = new TemporalQueue(); + for (int i = 0; i < 5; i++) q.Enqueue(i); + + var earliest = q.GetEarliest()!; + var cutoff = earliest.Timestamp.UtcDateTime.AddTicks(-1); + + Assert.Equal(q.Count, q.CountSince(cutoff)); + } + + [Fact] + public void GetNearest_EmptyQueue_ShouldReturnNull() + { + var q = new TemporalQueue(); + Assert.Null(q.GetNearest(DateTime.UtcNow)); + } + + [Fact] + public void GetNearest_BeforeAll_ShouldReturnEarliest() + { + var q = new TemporalQueue(); + q.Enqueue(1); + q.Enqueue(2); + q.Enqueue(3); + + var earliest = q.GetEarliest()!; + var target = earliest.Timestamp.UtcDateTime.AddTicks(-10); + + var nearest = q.GetNearest(target); + Assert.NotNull(nearest); + Assert.Equal(earliest.Value, nearest!.Value); + } + + [Fact] + public void GetNearest_AfterAll_ShouldReturnLatest() + { + var q = new TemporalQueue(); + q.Enqueue(1); + q.Enqueue(2); + q.Enqueue(3); + + var latest = q.GetLatest()!; + var target = latest.Timestamp.UtcDateTime.AddTicks(10); + + var nearest = q.GetNearest(target); + Assert.NotNull(nearest); + Assert.Equal(latest.Value, nearest!.Value); + } + + [Fact] + public void GetNearest_Tie_ShouldPreferLaterItem() + { + var q = new TemporalQueue(); + q.Enqueue("A"); + q.Enqueue("B"); + q.Enqueue("C"); + + var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + Assert.True(all.Count >= 3); + + var b = all[1]; + var c = all[2]; + + // Choose a target exactly midway between B and C (only if even delta) + long dt = c.Timestamp.UtcTicks - b.Timestamp.UtcTicks; + Assert.True(dt > 0); + + if ((dt % 2) != 0) + { + // If odd, shift to the closer boundary by constructing two near-mid points + var midFloor = new DateTimeOffset(b.Timestamp.UtcTicks + (dt / 2), TimeSpan.Zero).UtcDateTime; + var midCeil = new DateTimeOffset(b.Timestamp.UtcTicks + (dt / 2) + 1, TimeSpan.Zero).UtcDateTime; + + Assert.Equal("B", q.GetNearest(midFloor)!.Value); + Assert.Equal("C", q.GetNearest(midCeil)!.Value); + } + else + { + var mid = new DateTimeOffset(b.Timestamp.UtcTicks + (dt / 2), TimeSpan.Zero).UtcDateTime; + + // Tie-break rule: prefer later (>= time) => should be C + var nearest = q.GetNearest(mid); + Assert.NotNull(nearest); + Assert.Equal("C", nearest!.Value); + } + } + + [Fact] + public void RemoveOlderThan_WhenCutoffBeforeEarliest_ShouldBeNoOp() + { + var q = new TemporalQueue(); + q.Enqueue(1); + q.Enqueue(2); + q.Enqueue(3); + + var earliest = q.GetEarliest()!; + var cutoff = earliest.Timestamp.UtcDateTime.AddTicks(-1); + + q.RemoveOlderThan(cutoff); + + Assert.Equal(3, q.Count); + Assert.Equal(1, q.Peek().Value); + } + + [Fact] + public void RemoveOlderThan_WhenCutoffAfterLatest_ShouldClearQueue() + { + var q = new TemporalQueue(); + q.Enqueue(1); + q.Enqueue(2); + q.Enqueue(3); + + var latest = q.GetLatest()!; + var cutoff = latest.Timestamp.UtcDateTime.AddTicks(1); + + q.RemoveOlderThan(cutoff); + + Assert.Equal(0, q.Count); + Assert.Empty(q.GetInRange(DateTime.MinValue, DateTime.MaxValue)); + } + + [Fact] + public void RemoveRange_WhenBoundsAreReversed_ShouldStillRemove() + { + var q = new TemporalQueue(); + q.Enqueue(1); + q.Enqueue(2); + q.Enqueue(3); + q.Enqueue(4); + + var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var t2 = all[1].Timestamp.UtcDateTime; + var t3 = all[2].Timestamp.UtcDateTime; + + // reversed input [t3, t2] -> internally swapped -> should remove 2 and 3 + q.RemoveRange(t3, t2); + + var remaining = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).Select(x => x.Value).ToList(); + Assert.DoesNotContain(2, remaining); + Assert.DoesNotContain(3, remaining); + Assert.Contains(1, remaining); + Assert.Contains(4, remaining); + } + + [Fact] + public void GetEarliest_ShouldRemainFront_AfterRemoveRange() + { + var q = new TemporalQueue(); + q.Enqueue(1); + q.Enqueue(2); + q.Enqueue(3); + q.Enqueue(4); + + var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var t2 = all[1].Timestamp.UtcDateTime; + var t3 = all[2].Timestamp.UtcDateTime; + + q.RemoveRange(t2, t3); // remove 2 and 3 + + var earliest = q.GetEarliest()!; + Assert.Equal(1, earliest.Value); + + var firstDeq = q.Dequeue(); + Assert.Equal(1, firstDeq.Value); + } + + [Fact] + public void SnapshotSemantics_GetInRange_ShouldNotChangeDuringConcurrentEnqueue() + { + var q = new TemporalQueue(); + + // Seed + for (int i = 0; i < 100; i++) q.Enqueue(i); + + // Take a snapshot while we enqueue concurrently + var t = Task.Run(() => + { + for (int i = 100; i < 500; i++) q.Enqueue(i); + }); + + var snapshot = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + + t.GetAwaiter().GetResult(); + + // Snapshot must be internally consistent: timestamps non-decreasing and count <= final count + Assert.True(snapshot.Count <= q.Count); + + for (int i = 1; i < snapshot.Count; i++) + Assert.True(snapshot[i - 1].Timestamp <= snapshot[i].Timestamp); + } + + [Fact] + public void TemporalItem_Create_ShouldBeStrictlyMonotonic_UnderHighConcurrency() + { + // This test targets TemporalItem.Create invariants indirectly via queue enqueue. + var q = new TemporalQueue(); + + Parallel.For(0, 10_000, i => q.Enqueue(i)); + + var items = q.GetInRange(DateTime.MinValue, DateTime.MaxValue) + .OrderBy(x => x.Timestamp.UtcTicks) + .ToList(); + + Assert.Equal(10_000, items.Count); + + for (int i = 1; i < items.Count; i++) + { + Assert.True(items[i - 1].Timestamp.UtcTicks < items[i].Timestamp.UtcTicks, + $"Non-monotonic ticks at index {i}"); + } + } } } \ No newline at end of file From e0af0160fae1bd31c264d55bad585826b1202a44 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 30 Jan 2026 10:53:00 +0100 Subject: [PATCH 06/34] test(TemporalStack): add high-value edge-case and concurrency tests --- .../Collections/TemporalStackTests.cs | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/src/TemporalCollections.Tests/Collections/TemporalStackTests.cs b/src/TemporalCollections.Tests/Collections/TemporalStackTests.cs index 991d6cd..b3a8bfd 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalStackTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalStackTests.cs @@ -496,5 +496,215 @@ public void TemporalStack_GetNearest_WorksAndTiesPreferLater() Assert.Equal("C", nearCeil!.Value); } } + + [Fact] + public void GetInRange_WhenFromIsGreaterThanTo_ShouldSwapBounds() + { + var stack = new TemporalStack(); + stack.Push(1); + Thread.Sleep(2); + var t1 = DateTime.UtcNow; + Thread.Sleep(2); + stack.Push(2); + Thread.Sleep(2); + var t2 = DateTime.UtcNow; + Thread.Sleep(2); + stack.Push(3); + + // normal + var normal = stack.GetInRange(t1, t2).Select(x => x.Value).ToList(); + // swapped + var swapped = stack.GetInRange(t2, t1).Select(x => x.Value).ToList(); + + Assert.Equal(normal, swapped); + } + + [Fact] + public void CountInRange_WhenFromIsGreaterThanTo_ShouldSwapBounds() + { + var stack = new TemporalStack(); + stack.Push(1); + Thread.Sleep(2); + var t1 = DateTime.UtcNow; + Thread.Sleep(2); + stack.Push(2); + Thread.Sleep(2); + var t2 = DateTime.UtcNow; + Thread.Sleep(2); + stack.Push(3); + + var a = stack.CountInRange(t1, t2); + var b = stack.CountInRange(t2, t1); + + Assert.Equal(a, b); + } + + [Fact] + public void RemoveRange_WhenFromIsGreaterThanTo_ShouldSwapBounds() + { + var stack = new TemporalStack(); + stack.Push(1); + Thread.Sleep(2); + var t1 = DateTime.UtcNow; + Thread.Sleep(2); + stack.Push(2); + Thread.Sleep(2); + var t2 = DateTime.UtcNow; + Thread.Sleep(2); + stack.Push(3); + + // Remove item "2" by range [t1, t2] (or swapped) + stack.RemoveRange(t2, t1); + + var remaining = stack.GetInRange(DateTime.MinValue, DateTime.MaxValue).Select(x => x.Value).ToList(); + Assert.Contains(1, remaining); + Assert.Contains(3, remaining); + Assert.DoesNotContain(2, remaining); + } + + [Fact] + public void GetNearest_WhenTimeIsBeforeEarliest_ShouldReturnEarliest() + { + var stack = new TemporalStack(); + stack.Push("A"); + Thread.Sleep(2); + stack.Push("B"); + + var earliest = stack.GetEarliest(); + Assert.NotNull(earliest); + + var beforeAll = earliest!.Timestamp.UtcDateTime.AddMinutes(-10); + + var nearest = stack.GetNearest(beforeAll); + + Assert.NotNull(nearest); + Assert.Equal(earliest.Value, nearest!.Value); + Assert.Equal(earliest.Timestamp, nearest.Timestamp); + } + + [Fact] + public void GetNearest_WhenTimeIsAfterLatest_ShouldReturnLatest() + { + var stack = new TemporalStack(); + stack.Push("A"); + Thread.Sleep(2); + stack.Push("B"); + + var latest = stack.GetLatest(); + Assert.NotNull(latest); + + var afterAll = latest!.Timestamp.UtcDateTime.AddMinutes(+10); + + var nearest = stack.GetNearest(afterAll); + + Assert.NotNull(nearest); + Assert.Equal(latest.Value, nearest!.Value); + Assert.Equal(latest.Timestamp, nearest.Timestamp); + } + + [Fact] + public void GetBefore_And_GetAfter_ShouldReturnResultsSortedByAscendingTimestamp() + { + var stack = new TemporalStack(); + stack.Push(1); + Thread.Sleep(2); + stack.Push(2); + Thread.Sleep(2); + var split = DateTime.UtcNow; + Thread.Sleep(2); + stack.Push(3); + Thread.Sleep(2); + stack.Push(4); + + var before = stack.GetBefore(split).ToList(); + var after = stack.GetAfter(split).ToList(); + + static bool IsSortedAscending(IReadOnlyList> items) + { + for (int i = 1; i < items.Count; i++) + if (items[i - 1].Timestamp.UtcTicks > items[i].Timestamp.UtcTicks) + return false; + return true; + } + + Assert.True(IsSortedAscending(before)); + Assert.True(IsSortedAscending(after)); + } + + [Fact] + public void GetEarliest_And_GetLatest_ShouldUpdateAfterRemoveOlderThan() + { + var stack = new TemporalStack(); + + stack.Push("old"); + Thread.Sleep(5); + var cutoff = DateTime.UtcNow; + Thread.Sleep(5); + stack.Push("new1"); + Thread.Sleep(5); + stack.Push("new2"); + + stack.RemoveOlderThan(cutoff); + + var earliest = stack.GetEarliest(); + var latest = stack.GetLatest(); + + Assert.NotNull(earliest); + Assert.NotNull(latest); + + Assert.Equal("new1", earliest!.Value); + Assert.Equal("new2", latest!.Value); + } + + [Fact] + public void Operations_OnEmptyStack_ShouldBeSafeForQueriesAndMutations() + { + var stack = new TemporalStack(); + + // Query APIs should not throw and should return empty/zero/null + Assert.Empty(stack.GetInRange(DateTime.MinValue, DateTime.MaxValue)); + Assert.Empty(stack.GetBefore(DateTime.UtcNow)); + Assert.Empty(stack.GetAfter(DateTime.UtcNow)); + Assert.Equal(0, stack.CountInRange(DateTime.MinValue, DateTime.MaxValue)); + Assert.Equal(0, stack.CountSince(DateTime.UtcNow)); + Assert.Equal(TimeSpan.Zero, stack.GetTimeSpan()); + Assert.Null(stack.GetEarliest()); + Assert.Null(stack.GetLatest()); + Assert.Null(stack.GetNearest(DateTime.UtcNow)); + + // Mutation APIs should not throw + stack.RemoveOlderThan(DateTime.UtcNow); + stack.RemoveRange(DateTime.MinValue, DateTime.MaxValue); + stack.Clear(); + } + + [Fact] + public void Concurrent_Reads_DuringPushes_ShouldNotThrow_AndCountShouldMatchAllItemsAtEnd() + { + var stack = new TemporalStack(); + + var reader = Task.Run(() => + { + // read a bunch while writers are pushing; should never throw + for (int i = 0; i < 2000; i++) + { + _ = stack.Count; + _ = stack.GetInRange(DateTime.MinValue, DateTime.MaxValue).Count(); + _ = stack.GetEarliest(); + _ = stack.GetLatest(); + } + }); + + Parallel.For(0, 1000, i => stack.Push(i)); + + reader.Wait(); + + var all = stack.GetInRange(DateTime.MinValue, DateTime.MaxValue).Select(x => x.Value).ToList(); + Assert.Equal(1000, stack.Count); + Assert.Equal(1000, all.Count); + + // no duplicates / no losses + Assert.Equal(1000, all.Distinct().Count()); + } } } \ No newline at end of file From cb2e0899a621dc020d474badd13142be594c8274 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 8 Feb 2026 17:11:05 +0100 Subject: [PATCH 07/34] test(temporal-multimap): add edge-case coverage for ranges, DateTime overloads, and nearest ties --- .../Collections/TemporalMultimapTests.cs | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs b/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs index dfc08ac..864efbb 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs @@ -1,5 +1,6 @@ // (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) +using System.Globalization; using TemporalCollections.Collections; using TemporalCollections.Models; @@ -300,5 +301,243 @@ public void Concurrency_AddValue_MultiKey() for (int i = 1; i < all.Length; i++) Assert.True(all[i - 1].Timestamp.UtcTicks <= all[i].Timestamp.UtcTicks); } + + [Fact(DisplayName = "Empty collection: queries return empty / counts 0 / nearest null")] + public void Empty_Behavior_IsConsistent() + { + var map = new TemporalMultimap(); + + Assert.Equal(0, map.Count); + Assert.Equal(0, map.KeyCount); + + Assert.Empty(map.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); + Assert.Empty(map.GetBefore(DateTimeOffset.UtcNow)); + Assert.Empty(map.GetAfter(DateTimeOffset.UtcNow)); + Assert.Equal(0, map.CountInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); + Assert.Equal(0, map.CountSince(DateTimeOffset.UtcNow.AddDays(-1))); + Assert.Null(map.GetNearest(DateTimeOffset.UtcNow)); + Assert.Null(map.GetLatest()); + Assert.Null(map.GetEarliest()); + Assert.Equal(TimeSpan.Zero, map.GetTimeSpan()); + } + + [Fact(DisplayName = "GetValuesInRange is inclusive on boundaries (from/to)")] + public void PerKey_GetValuesInRange_InclusiveBoundaries() + { + var map = new TemporalMultimap(); + var items = AddValuesForKey(map, "A", 10, 20, 30); + + var exactFirst = map.GetValuesInRange("A", items[0].Timestamp, items[0].Timestamp).ToArray(); + Assert.Single(exactFirst); + Assert.Equal(10, exactFirst[0].Value); + + var exactLast = map.GetValuesInRange("A", items[2].Timestamp, items[2].Timestamp).ToArray(); + Assert.Single(exactLast); + Assert.Equal(30, exactLast[0].Value); + + var middle = map.GetValuesInRange("A", items[1].Timestamp, items[1].Timestamp).ToArray(); + Assert.Single(middle); + Assert.Equal(20, middle[0].Value); + } + + [Fact(DisplayName = "Range arguments can be swapped: GetInRange/RemoveRange/CountInRange behave same")] + public void RangeArguments_Swapped_AreHandled() + { + var map = new TemporalMultimap(); + var a = AddValuesForKey(map, "A", 1, 2, 3, 4, 5); + + // Same results even if from > to + var r1 = map.GetInRange(a[1].Timestamp, a[3].Timestamp).Select(x => x.Value.Value).ToArray(); + var r2 = map.GetInRange(a[3].Timestamp, a[1].Timestamp).Select(x => x.Value.Value).ToArray(); + Assert.Equal(r1, r2); + + var c1 = map.CountInRange(a[1].Timestamp, a[3].Timestamp); + var c2 = map.CountInRange(a[3].Timestamp, a[1].Timestamp); + Assert.Equal(c1, c2); + + // RemoveRange swapped should remove the same number of items (inclusive) + var map2 = new TemporalMultimap(); + var b = AddValuesForKey(map2, "A", 1, 2, 3, 4, 5); + map2.RemoveRange(b[3].Timestamp, b[1].Timestamp); // swapped + var left = map2.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).Select(x => x.Value).ToArray(); + Assert.Equal(new[] { 1, 5 }, left); + } + + [Fact(DisplayName = "Per-key RemoveOlderThan is strictly older: cutoff timestamp remains")] + public void RemoveOlderThan_StrictlyOlder_CutoffStays() + { + var map = new TemporalMultimap(); + var items = AddValuesForKey(map, "A", 10, 20, 30); + + // cutoff = timestamp of 20, must remove only 10 + int removed = map.RemoveOlderThan("A", items[1].Timestamp); + Assert.Equal(1, removed); + + var left = map.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + Assert.Equal(new[] { 20, 30 }, left.Select(x => x.Value).ToArray()); + } + + [Fact(DisplayName = "Global RemoveOlderThan is strictly older: cutoff timestamp remains (across keys)")] + public void Global_RemoveOlderThan_StrictlyOlder_CutoffStays() + { + var map = new TemporalMultimap(); + var a = AddValuesForKey(map, "A", 1, 2, 3); + var b = AddValuesForKey(map, "B", 10, 20); + + // cutoff = A:2 timestamp => remove only A:1 (strictly older) + map.RemoveOlderThan(a[1].Timestamp); + + var all = map.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + Assert.DoesNotContain(all, it => it.Value.Key == "A" && it.Value.Value == 1); + Assert.Contains(all, it => it.Value.Key == "A" && it.Value.Value == 2); + Assert.Contains(all, it => it.Value.Key == "B" && it.Value.Value == 10); + } + + [Fact(DisplayName = "CountForKey/ContainsKey reflect key removal after last item deleted")] + public void KeyBookkeeping_AfterDeletions_IsCorrect() + { + var map = new TemporalMultimap(); + var a = AddValuesForKey(map, "A", 1, 2, 3); + + Assert.True(map.ContainsKey("A")); + Assert.Equal(3, map.CountForKey("A")); + Assert.Equal(1, map.KeyCount); + + // Remove all via per-key range + int removed = map.RemoveRange("A", a[0].Timestamp, a[2].Timestamp); + Assert.Equal(3, removed); + + Assert.False(map.ContainsKey("A")); + Assert.Equal(0, map.CountForKey("A")); + Assert.Equal(0, map.KeyCount); + Assert.Equal(0, map.Count); + } + + [Fact(DisplayName = "GetNearest tie across keys prefers earlier timestamp (global tie-break rule)")] + public void GetNearest_TieAcrossKeys_PrefersEarlier() + { + var map = new TemporalMultimap(); + + // Build two items around a midpoint by crafting a manual timestamp between two AddValue calls + var a1 = map.AddValue("A", 100); + var a2 = map.AddValue("A", 200); + + // Create B item exactly at midpoint between a1 and a2 (manual add) + var mid = Mid(a1.Timestamp, a2.Timestamp); + map.Add(new TemporalItem>(new KeyValuePair("B", 999), mid)); + + // Query at same midpoint: nearest should be the midpoint item itself (diff=0) + var nearest = map.GetNearest(mid); + Assert.NotNull(nearest); + Assert.Equal("B", nearest!.Value.Key); + Assert.Equal(999, nearest.Value.Value); + + // Now query at a time equidistant between a1 and mid: + // nearest should be the earlier one on tie (a1 vs mid if symmetric) + var mid2 = Mid(a1.Timestamp, mid); + nearest = map.GetNearest(mid2); + Assert.NotNull(nearest); + + // Tie scenario depends on integer tick division; enforce the intended rule: + // if equal diff, implementation prefers earlier timestamp. + // So nearest must NOT have timestamp greater than the other candidate with same diff. + // We validate by checking it is one of the two and that it is the earlier if diffs match. + var cand1 = map.GetInRange(a1.Timestamp, a1.Timestamp).Single(); // A:100 + var cand2 = map.GetInRange(mid, mid).Single(); // B:999 + + long d1 = Math.Abs(cand1.Timestamp.UtcTicks - mid2.UtcTicks); + long d2 = Math.Abs(cand2.Timestamp.UtcTicks - mid2.UtcTicks); + + if (d1 == d2) + Assert.Equal(cand1.Timestamp.UtcTicks, nearest.Timestamp.UtcTicks); // earlier wins + else + Assert.Equal(d1 < d2 ? cand1.Timestamp.UtcTicks : cand2.Timestamp.UtcTicks, nearest.Timestamp.UtcTicks); + } + + [Fact(DisplayName = "DateTime overloads: Unspecified is treated as UTC (AssumeUtc policy)")] + public void DateTimeOverloads_Unspecified_AssumeUtc() + { + var map = new TemporalMultimap(); + var items = AddValuesForKey(map, "A", 1, 2, 3); + + // Use DateTime overloads with Kind=Unspecified but same ticks as UTC DateTime + DateTime fromUnspec = DateTime.SpecifyKind(items[0].Timestamp.UtcDateTime, DateTimeKind.Unspecified); + DateTime toUnspec = DateTime.SpecifyKind(items[2].Timestamp.UtcDateTime, DateTimeKind.Unspecified); + + var res = map.GetInRange(fromUnspec, toUnspec).ToArray(); + Assert.Equal(3, res.Length); + + // RemoveOlderThan via DateTime overload: cutoff stays (strictly older) + DateTime cutoffUnspec = DateTime.SpecifyKind(items[1].Timestamp.UtcDateTime, DateTimeKind.Unspecified); + map.RemoveOlderThan(cutoffUnspec); + + var left = map.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).Select(x => x.Value).ToArray(); + Assert.Equal(new[] { 2, 3 }, left); + } + + [Fact(DisplayName = "Global ordering: GetInRange returns results globally sorted even with many keys")] + public void Global_GetInRange_IsGloballySorted_ManyKeys() + { + var map = new TemporalMultimap(); + for (int k = 0; k < 20; k++) + { + string key = "K" + k.ToString(CultureInfo.InvariantCulture); + for (int i = 0; i < 15; i++) + map.AddValue(key, i); + } + + var all = map.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + Assert.Equal(20 * 15, all.Length); + + for (int i = 1; i < all.Length; i++) + Assert.True(all[i - 1].Timestamp.UtcTicks <= all[i].Timestamp.UtcTicks); + } + + [Fact(DisplayName = "AddRange(items): supports mixed keys and preserves order within each key")] + public void AddRange_Items_MixedKeys_PreservesPerKeyOrder() + { + var map = new TemporalMultimap(); + + // Create a bunch of items with explicit timestamps (some interleaving) + var baseTicks = DateTimeOffset.UtcNow.UtcTicks; + var items = new[] + { + new TemporalItem>(new("A", 1), new DateTimeOffset(baseTicks + 10, TimeSpan.Zero)), + new TemporalItem>(new("B", 1), new DateTimeOffset(baseTicks + 11, TimeSpan.Zero)), + new TemporalItem>(new("A", 2), new DateTimeOffset(baseTicks + 12, TimeSpan.Zero)), + // Out of order within A to force binary insert + new TemporalItem>(new("A", 99), new DateTimeOffset(baseTicks + 9, TimeSpan.Zero)), + new TemporalItem>(new("B", 2), new DateTimeOffset(baseTicks + 13, TimeSpan.Zero)), + }; + + map.AddRange(items); + + var a = map.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + AssertStrictlyIncreasing(a); + Assert.Equal(new[] { 99, 1, 2 }, a.Select(x => x.Value).ToArray()); + + var b = map.GetValuesInRange("B", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + AssertStrictlyIncreasing(b); + Assert.Equal(new[] { 1, 2 }, b.Select(x => x.Value).ToArray()); + } + + [Fact(DisplayName = "GetTimeSpan: single item returns zero, multiple items returns (max-min) across keys")] + public void GetTimeSpan_SingleAndMulti() + { + var map = new TemporalMultimap(); + map.AddValue("A", 1); + Assert.Equal(TimeSpan.Zero, map.GetTimeSpan()); + + var a2 = map.AddValue("A", 2); + var b1 = map.AddValue("B", 10); + + var earliest = map.GetEarliest()!; + var latest = map.GetLatest()!; + Assert.Equal(latest.Timestamp - earliest.Timestamp, map.GetTimeSpan()); + + // sanity: latest should be >= both + Assert.True(latest.Timestamp.UtcTicks >= a2.Timestamp.UtcTicks); + Assert.True(latest.Timestamp.UtcTicks >= b1.Timestamp.UtcTicks); + } } } \ No newline at end of file From 9a7435b3f1ebb3a8f789dd74f07ebbb25266b8f7 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 8 Feb 2026 21:56:52 +0100 Subject: [PATCH 08/34] test(temporal-priority-queue): add edge-case coverage for empty queue, swapped ranges, DateTime overloads, ordering, and nearest ties --- .../Collections/TemporalPriorityQueueTests.cs | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) diff --git a/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs b/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs index c94298e..4627aff 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs @@ -1,6 +1,7 @@ // (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using TemporalCollections.Collections; +using TemporalCollections.Models; namespace TemporalCollections.Tests.Collections { @@ -359,5 +360,245 @@ public void TemporalPriorityQueue_GetNearest_WorksAndTiesPreferLater() Assert.Equal("C", nearCeil!.Value); } } + + // --- Helpers (local to this test class) --- + private static void AssertSortedByTimestamp(IReadOnlyList> items) + { + for (int i = 1; i < items.Count; i++) + { + Assert.True(items[i - 1].Timestamp.UtcTicks <= items[i].Timestamp.UtcTicks, + $"Not sorted by timestamp at {i - 1}->{i}: {items[i - 1].Timestamp:o} > {items[i].Timestamp:o}"); + } + } + + private static DateTimeOffset Mid(DateTimeOffset a, DateTimeOffset b) + { + long m = (a.UtcTicks + b.UtcTicks) / 2; + return new DateTimeOffset(m, TimeSpan.Zero); + } + + [Fact(DisplayName = "Empty queue: TryPeek/TryDequeue return false and default/null value")] + public void EmptyQueue_TryPeek_TryDequeue_ReturnFalse() + { + var q = new TemporalPriorityQueue(); + + Assert.False(q.TryPeek(out var p)); + Assert.Null(p); + + Assert.False(q.TryDequeue(out var d)); + Assert.Null(d); + + Assert.Equal(0, q.Count); + Assert.Empty(q.GetInRange(DateTime.MinValue, DateTime.MaxValue)); + Assert.Null(q.GetEarliest()); + Assert.Null(q.GetLatest()); + Assert.Equal(TimeSpan.Zero, q.GetTimeSpan()); + } + + [Fact(DisplayName = "Dequeue: stable ordering for same priority (FIFO by insertion timestamp)")] + public void TryDequeue_SamePriority_IsStableByInsertionTime() + { + var q = new TemporalPriorityQueue(); + + // Same priority for all: should be dequeued by insertion timestamp (stable FIFO). + q.Enqueue("A", 5); + q.Enqueue("B", 5); + q.Enqueue("C", 5); + + Assert.True(q.TryDequeue(out var v1)); + Assert.True(q.TryDequeue(out var v2)); + Assert.True(q.TryDequeue(out var v3)); + + Assert.Equal("A", v1); + Assert.Equal("B", v2); + Assert.Equal("C", v3); + Assert.Equal(0, q.Count); + } + + [Fact(DisplayName = "Time queries are ordered by timestamp (not by priority)")] + public void GetInRange_IsOrderedByTimestamp_NotByPriority() + { + var q = new TemporalPriorityQueue(); + + // Enqueue with priorities that would reorder if we were sorting by priority: + // but GetInRange must return ordered by timestamp. + q.Enqueue("first-lowprio", 100); // inserted 1st + q.Enqueue("second-highprio", 1); // inserted 2nd + q.Enqueue("third-midprio", 50); // inserted 3rd + + var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + Assert.Equal(3, all.Count); + AssertSortedByTimestamp(all); + + Assert.Equal("first-lowprio", all[0].Value); + Assert.Equal("second-highprio", all[1].Value); + Assert.Equal("third-midprio", all[2].Value); + } + + [Fact(DisplayName = "RemoveOlderThan is strictly older: cutoff item remains")] + public void RemoveOlderThan_StrictlyOlder_CutoffRemains() + { + var q = new TemporalPriorityQueue(); + + q.Enqueue("A", 2); + q.Enqueue("B", 1); + q.Enqueue("C", 3); + + var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).OrderBy(x => x.Timestamp.UtcTicks).ToList(); + Assert.True(all.Count >= 3); + + // cutoff = timestamp of the second item => must remove only the first (strictly older) + var cutoff = all[1].Timestamp.UtcDateTime; + q.RemoveOlderThan(cutoff); + + var left = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).OrderBy(x => x.Timestamp.UtcTicks).ToList(); + Assert.Equal(2, left.Count); + Assert.Equal(all[1].Value, left[0].Value); // cutoff item remains + Assert.Equal(all[2].Value, left[1].Value); + } + + [Fact(DisplayName = "RemoveRange is inclusive on boundaries [from,to]")] + public void RemoveRange_InclusiveBoundaries_RemovesEndpoints() + { + var q = new TemporalPriorityQueue(); + + q.Enqueue("A", 3); + q.Enqueue("B", 2); + q.Enqueue("C", 1); + + var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).OrderBy(x => x.Timestamp.UtcTicks).ToList(); + Assert.True(all.Count == 3); + + // Remove exactly item B by using same timestamp as start/end + var tsB = all[1].Timestamp.UtcDateTime; + q.RemoveRange(tsB, tsB); + + var left = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).Select(x => x.Value).ToList(); + Assert.Equal(2, left.Count); + Assert.DoesNotContain("B", left); + } + + [Fact(DisplayName = "Range arguments can be swapped: GetInRange/CountInRange/RemoveRange behave same")] + public void RangeArguments_Swapped_AreHandled() + { + var q1 = new TemporalPriorityQueue(); + q1.Enqueue("A", 3); + q1.Enqueue("B", 2); + q1.Enqueue("C", 1); + + var all1 = q1.GetInRange(DateTime.MinValue, DateTime.MaxValue).OrderBy(x => x.Timestamp.UtcTicks).ToList(); + var from = all1[0].Timestamp.UtcDateTime; + var to = all1[2].Timestamp.UtcDateTime; + + var r1 = q1.GetInRange(from, to).Select(x => x.Value).ToArray(); + var r2 = q1.GetInRange(to, from).Select(x => x.Value).ToArray(); + Assert.Equal(r1, r2); + + var c1 = q1.CountInRange(from, to); + var c2 = q1.CountInRange(to, from); + Assert.Equal(c1, c2); + + // RemoveRange swapped removes same items + var q2 = new TemporalPriorityQueue(); + q2.Enqueue("A", 3); + q2.Enqueue("B", 2); + q2.Enqueue("C", 1); + + var all2 = q2.GetInRange(DateTime.MinValue, DateTime.MaxValue).OrderBy(x => x.Timestamp.UtcTicks).ToList(); + var midTs = all2[1].Timestamp.UtcDateTime; + + // Remove only middle item but with swapped args around same point (still inclusive) + q2.RemoveRange(midTs, midTs); + var left = q2.GetInRange(DateTime.MinValue, DateTime.MaxValue).Select(x => x.Value).ToList(); + Assert.Equal(2, left.Count); + Assert.DoesNotContain(all2[1].Value, left); + } + + [Fact(DisplayName = "DateTime overloads: Unspecified is treated as UTC (AssumeUtc policy from base)")] + public void DateTimeOverloads_Unspecified_AssumeUtc() + { + var q = new TemporalPriorityQueue(); + q.Enqueue("A", 3); + q.Enqueue("B", 2); + q.Enqueue("C", 1); + + var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue) + .OrderBy(x => x.Timestamp.UtcTicks) + .ToList(); + + // Build DateTime range with Kind=Unspecified but same clock values as UTC + var fromUnspec = DateTime.SpecifyKind(all[0].Timestamp.UtcDateTime, DateTimeKind.Unspecified); + var toUnspec = DateTime.SpecifyKind(all[2].Timestamp.UtcDateTime, DateTimeKind.Unspecified); + + var res = q.GetInRange(fromUnspec, toUnspec).ToList(); + Assert.Equal(3, res.Count); + } + + [Fact(DisplayName = "GetNearest: tie prefers later item (>= time) as documented")] + public void GetNearest_TiePrefersLater_WhenExactMidpointExists() + { + // Wide range for retrieving all items + DateTime wideFrom = new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime wideTo = new(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + var q = new TemporalPriorityQueue(); + q.Enqueue("A", 10); + q.Enqueue("B", 10); + q.Enqueue("C", 10); + + var all = q.GetInRange(wideFrom, wideTo).OrderBy(x => x.Timestamp.UtcTicks).ToList(); + Assert.True(all.Count >= 3); + + var b = all[1]; + var c = all[2]; + + long dt = c.Timestamp.UtcTicks - b.Timestamp.UtcTicks; + Assert.True(dt > 0); + + if ((dt & 1L) == 0L) + { + // Exact midpoint exists -> tie case + var mid = Mid(b.Timestamp, c.Timestamp).UtcDateTime; + + var nearest = q.GetNearest(mid); + Assert.NotNull(nearest); + Assert.Equal("C", nearest!.Value); // later preferred on tie + } + else + { + // No exact midpoint; test both sides: one tick closer to B, next tick closer to C + long midFloorTicks = b.Timestamp.UtcTicks + (dt / 2); + long midCeilTicks = midFloorTicks + 1; + + var nearB = q.GetNearest(new DateTimeOffset(midFloorTicks, TimeSpan.Zero).UtcDateTime); + var nearC = q.GetNearest(new DateTimeOffset(midCeilTicks, TimeSpan.Zero).UtcDateTime); + + Assert.NotNull(nearB); + Assert.NotNull(nearC); + Assert.Equal("B", nearB!.Value); + Assert.Equal("C", nearC!.Value); + } + } + + [Fact(DisplayName = "Concurrency: timestamps remain strictly increasing in time queries")] + public void Concurrency_Enqueue_ProducesMonotonicTimestamps_InQueries() + { + var q = new TemporalPriorityQueue(); + + Parallel.For(0, 2000, i => + { + q.Enqueue(i, i % 7); + }); + + Assert.Equal(2000, q.Count); + + var all = q.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + Assert.Equal(2000, all.Count); + AssertSortedByTimestamp(all); + + // Extra safety: no duplicates dropped (SortedSet strict ordering) + var seen = new HashSet(all.Select(x => x.Value)); + Assert.Equal(2000, seen.Count); + } } } \ No newline at end of file From 56263b1a595d52cac5c8f198034ca9e9c7e1c84e Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 8 Feb 2026 22:32:10 +0100 Subject: [PATCH 09/34] chore(tests): migrate to xunit.v3 --- src/TemporalCollections.Tests/TemporalCollections.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index 818f814..665a24c 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -13,11 +13,11 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - all runtime; build; native; contentfiles; analyzers; buildtransitive + From 9f006491447b640f1fd603f347a537651d403246 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 23 Feb 2026 16:17:12 +0100 Subject: [PATCH 10/34] chore(deps): update dependencies --- src/TemporalCollections.Tests/TemporalCollections.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index 665a24c..b5a3904 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -8,7 +8,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 8bdfc82e8e2a0da9e97b175e57b815b64f9180c2 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Wed, 25 Feb 2026 16:44:41 +0100 Subject: [PATCH 11/34] chore(deps): update dependencies --- src/TemporalCollections.Tests/TemporalCollections.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index b5a3904..3c5ae9a 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -12,7 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 99d3b905d3237760b635af7e0ace3a8199636f38 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 28 Feb 2026 22:06:07 +0100 Subject: [PATCH 12/34] fix: correct GetNearest tie-breaking and TemporalDictionary thread-safety - Fix race condition in TemporalDictionary.Add where concurrent RemoveOlderThan/RemoveRange could orphan a list, silently losing items - Fix GetNearest tie-breaking in TemporalMultimap and TemporalSegmentedArray to prefer the later item (>= time), consistent with all other collections - Prevent IndexOutOfRangeException in TemporalSegmentedArray when segmentCapacity == 1 by requiring minimum capacity of 2 - Remove unused TimestampOnlyComparerImpl from TemporalDictionary - Update related tests to match corrected tie-breaking behavior --- .../Collections/TemporalMultimapTests.cs | 29 +++++++----- .../TemporalSegmentedArrayTests.cs | 21 ++++++--- .../Collections/TemporalDictionary.cs | 46 ++++++++++--------- .../Collections/TemporalMultimap.cs | 44 ++++++++++-------- .../Collections/TemporalSegmentedArray.cs | 6 ++- 5 files changed, 88 insertions(+), 58 deletions(-) diff --git a/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs b/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs index 864efbb..e22e301 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs @@ -196,8 +196,8 @@ public void Global_Counts() Assert.Equal(2, map.CountSince(b[0].Timestamp)); // >= 40 → 40,50 } - [Fact(DisplayName = "GetNearest picks the nearest by ticks; on tie prefers the earlier")] - public void Global_GetNearest_TiePrefersEarlier() + [Fact(DisplayName = "GetNearest picks the nearest by ticks; on tie prefers the later (>= time)")] + public void Global_GetNearest_TiePrefersLater() { var map = new TemporalMultimap(); var items = AddValuesForKey(map, "A", 100, 200, 300); @@ -205,12 +205,21 @@ public void Global_GetNearest_TiePrefersEarlier() var mid = Mid(items[1].Timestamp, items[2].Timestamp); // between 200 and 300 var nearest = map.GetNearest(mid); Assert.NotNull(nearest); - Assert.Equal(200, nearest!.Value.Value); // earlier on tie + + long dBefore = Math.Abs(mid.UtcTicks - items[1].Timestamp.UtcTicks); + long dAfter = Math.Abs(items[2].Timestamp.UtcTicks - mid.UtcTicks); + // If true tie, prefer later; otherwise prefer closer + int expected1 = (dAfter <= dBefore) ? 300 : 200; + Assert.Equal(expected1, nearest!.Value.Value); var mid2 = Mid(items[0].Timestamp, items[1].Timestamp); nearest = map.GetNearest(mid2); Assert.NotNull(nearest); - Assert.Equal(100, nearest!.Value.Value); + + long dBefore2 = Math.Abs(mid2.UtcTicks - items[0].Timestamp.UtcTicks); + long dAfter2 = Math.Abs(items[1].Timestamp.UtcTicks - mid2.UtcTicks); + int expected2 = (dAfter2 <= dBefore2) ? 200 : 100; + Assert.Equal(expected2, nearest!.Value.Value); } [Fact(DisplayName = "GetLatest/GetEarliest/ GetTimeSpan are correct")] @@ -413,8 +422,8 @@ public void KeyBookkeeping_AfterDeletions_IsCorrect() Assert.Equal(0, map.Count); } - [Fact(DisplayName = "GetNearest tie across keys prefers earlier timestamp (global tie-break rule)")] - public void GetNearest_TieAcrossKeys_PrefersEarlier() + [Fact(DisplayName = "GetNearest tie across keys prefers later timestamp (global tie-break rule)")] + public void GetNearest_TieAcrossKeys_PrefersLater() { var map = new TemporalMultimap(); @@ -433,15 +442,13 @@ public void GetNearest_TieAcrossKeys_PrefersEarlier() Assert.Equal(999, nearest.Value.Value); // Now query at a time equidistant between a1 and mid: - // nearest should be the earlier one on tie (a1 vs mid if symmetric) + // nearest should be the later one on tie (consistent with all other collections) var mid2 = Mid(a1.Timestamp, mid); nearest = map.GetNearest(mid2); Assert.NotNull(nearest); // Tie scenario depends on integer tick division; enforce the intended rule: - // if equal diff, implementation prefers earlier timestamp. - // So nearest must NOT have timestamp greater than the other candidate with same diff. - // We validate by checking it is one of the two and that it is the earlier if diffs match. + // if equal diff, implementation prefers later timestamp (>= time). var cand1 = map.GetInRange(a1.Timestamp, a1.Timestamp).Single(); // A:100 var cand2 = map.GetInRange(mid, mid).Single(); // B:999 @@ -449,7 +456,7 @@ public void GetNearest_TieAcrossKeys_PrefersEarlier() long d2 = Math.Abs(cand2.Timestamp.UtcTicks - mid2.UtcTicks); if (d1 == d2) - Assert.Equal(cand1.Timestamp.UtcTicks, nearest.Timestamp.UtcTicks); // earlier wins + Assert.Equal(cand2.Timestamp.UtcTicks, nearest.Timestamp.UtcTicks); // later wins else Assert.Equal(d1 < d2 ? cand1.Timestamp.UtcTicks : cand2.Timestamp.UtcTicks, nearest.Timestamp.UtcTicks); } diff --git a/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs b/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs index 2048049..d54691c 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs @@ -165,23 +165,32 @@ public void RemoveRange_InclusiveAndDropsSegments() AssertStrictlyIncreasing(all); } - [Fact(DisplayName = "GetNearest returns nearest by ticks; in tie it prefers the earlier item")] - public void GetNearest_TiePrefersEarlier() + [Fact(DisplayName = "GetNearest returns nearest by ticks; in tie it prefers the later item (>= time)")] + public void GetNearest_TiePrefersLater() { var col = new TemporalSegmentedArray(segmentCapacity: 3); var items = AddValues(col, 100, 200, 300); - // Midpoint between items[1] and items[2] → tie, expect earlier (items[1]) + // Midpoint between items[1] and items[2] var mid = Mid(items[1].Timestamp, items[2].Timestamp); var nearest = col.GetNearest(mid); Assert.NotNull(nearest); - Assert.Equal(200, nearest!.Value); - // Midpoint between items[0] and items[1] → earlier (items[0]) + long dBefore = Math.Abs(mid.UtcTicks - items[1].Timestamp.UtcTicks); + long dAfter = Math.Abs(items[2].Timestamp.UtcTicks - mid.UtcTicks); + // If true tie, prefer later; otherwise prefer closer + int expected1 = (dAfter <= dBefore) ? 300 : 200; + Assert.Equal(expected1, nearest!.Value); + + // Midpoint between items[0] and items[1] var mid2 = Mid(items[0].Timestamp, items[1].Timestamp); nearest = col.GetNearest(mid2); Assert.NotNull(nearest); - Assert.Equal(100, nearest!.Value); + + long dBefore2 = Math.Abs(mid2.UtcTicks - items[0].Timestamp.UtcTicks); + long dAfter2 = Math.Abs(items[1].Timestamp.UtcTicks - mid2.UtcTicks); + int expected2 = (dAfter2 <= dBefore2) ? 200 : 100; + Assert.Equal(expected2, nearest!.Value); } [Fact(DisplayName = "GetLatest and GetEarliest return correct items")] diff --git a/src/TemporalCollections/Collections/TemporalDictionary.cs b/src/TemporalCollections/Collections/TemporalDictionary.cs index db2948a..c555e2d 100644 --- a/src/TemporalCollections/Collections/TemporalDictionary.cs +++ b/src/TemporalCollections/Collections/TemporalDictionary.cs @@ -19,29 +19,39 @@ public class TemporalDictionary : TimeQueryableBase>> _dict = new(); - private static readonly IComparer> TimestampOnlyComparer - = new TimestampOnlyComparerImpl(); - /// /// Adds a new value associated with the specified key, timestamped with the current UTC time. /// public void Add(TKey key, TValue value) { var temporalItem = TemporalItem.Create(value); - var list = _dict.GetOrAdd(key, _ => []); - lock (list) + // Retry loop: if a concurrent RemoveOlderThan/RemoveRange removes the key + // between GetOrAdd and lock(list), the list becomes orphaned. We detect this + // by verifying the list is still the one in the dictionary after acquiring the lock. + while (true) { - // Because timestamps are strictly increasing across all TemporalItem - // instances created via TemporalItem.Create, and we never insert items - // with arbitrary timestamps, we know that: - // - // - For each key, the sequence of timestamps in "list" is strictly increasing. - // - The new "temporalItem" always has the largest (most recent) timestamp. - // - // Therefore, we can safely append to the end of the list while preserving - // the sorted-by-timestamp invariant. No BinarySearch + Insert is needed. - list.Add(temporalItem); + var list = _dict.GetOrAdd(key, _ => []); + + lock (list) + { + // Verify this list is still the one associated with the key. + if (_dict.TryGetValue(key, out var current) && ReferenceEquals(current, list)) + { + // Because timestamps are strictly increasing across all TemporalItem + // instances created via TemporalItem.Create, and we never insert items + // with arbitrary timestamps, we know that: + // + // - For each key, the sequence of timestamps in "list" is strictly increasing. + // - The new "temporalItem" always has the largest (most recent) timestamp. + // + // Therefore, we can safely append to the end of the list while preserving + // the sorted-by-timestamp invariant. No BinarySearch + Insert is needed. + list.Add(temporalItem); + return; + } + } + // List was removed concurrently; retry with a fresh GetOrAdd. } } @@ -514,11 +524,5 @@ private static int UpperBound(List> list, long ticks) } #endregion - - private sealed class TimestampOnlyComparerImpl : IComparer> - { - public int Compare(TemporalItem? x, TemporalItem? y) - => x!.Timestamp.UtcTicks.CompareTo(y!.Timestamp.UtcTicks); - } } } \ No newline at end of file diff --git a/src/TemporalCollections/Collections/TemporalMultimap.cs b/src/TemporalCollections/Collections/TemporalMultimap.cs index 6d1d698..93fe515 100644 --- a/src/TemporalCollections/Collections/TemporalMultimap.cs +++ b/src/TemporalCollections/Collections/TemporalMultimap.cs @@ -456,6 +456,7 @@ public override int CountSince(DateTimeOffset from) long target = time.UtcTicks; TemporalItem>? best = null; long bestDiff = long.MaxValue; + bool bestIsAfterOrEqual = false; foreach (var list in _byKey.Values) { @@ -464,33 +465,40 @@ public override int CountSince(DateTimeOffset from) int idx = LowerBound(list, target); if (idx < list.Count) + Consider(list[idx]); + + if (idx > 0) + Consider(list[idx - 1]); + } + + return best; + + void Consider(TemporalItem> cand) + { + long ticks = cand.Timestamp.UtcTicks; + long diff = ticks >= target ? (ticks - target) : (target - ticks); + bool isAfterOrEqual = ticks >= target; + + if (diff < bestDiff) { - var cand = list[idx]; - long diff = cand.Timestamp.UtcTicks - target; - if (diff < 0) diff = -diff; - if (diff < bestDiff || - (diff == bestDiff && cand.Timestamp.UtcTicks < (best?.Timestamp.UtcTicks ?? long.MaxValue))) + bestDiff = diff; + bestIsAfterOrEqual = isAfterOrEqual; + best = cand; + } + else if (diff == bestDiff && best is not null) + { + // Tie-break: prefer the later item (>= target) + if (isAfterOrEqual && !bestIsAfterOrEqual) { + bestIsAfterOrEqual = true; best = cand; - bestDiff = diff; } - } - - if (idx > 0) - { - var cand = list[idx - 1]; - long diff = target - cand.Timestamp.UtcTicks; - if (diff < 0) diff = -diff; - if (diff < bestDiff || - (diff == bestDiff && cand.Timestamp.UtcTicks < (best?.Timestamp.UtcTicks ?? long.MaxValue))) + else if (isAfterOrEqual == bestIsAfterOrEqual && ticks > best.Timestamp.UtcTicks) { best = cand; - bestDiff = diff; } } } - - return best; } } diff --git a/src/TemporalCollections/Collections/TemporalSegmentedArray.cs b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs index 259bb67..c7c1e15 100644 --- a/src/TemporalCollections/Collections/TemporalSegmentedArray.cs +++ b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs @@ -145,7 +145,8 @@ public int UpperExclusive(long ticks) /// Maximum number of items per segment; must be > 0. public TemporalSegmentedArray(int segmentCapacity = DefaultSegmentCapacity) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(segmentCapacity); + if (segmentCapacity < 2) + throw new ArgumentOutOfRangeException(nameof(segmentCapacity), "Segment capacity must be at least 2."); _segmentCapacity = segmentCapacity; } @@ -514,7 +515,8 @@ public override int CountSince(DateTimeOffset from) return candBefore; long dPrev = Math.Abs(x - candBefore.Timestamp.UtcTicks); long dNext = Math.Abs(candAfter.Timestamp.UtcTicks - x); - return (dPrev <= dNext) ? candBefore : candAfter; + // Tie-break: prefer the later item (>= time) + return (dNext <= dPrev) ? candAfter : candBefore; } } From 9955edd41c3f2cf78c78563ee586c75a59e61c27 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 20 Mar 2026 19:47:57 +0100 Subject: [PATCH 13/34] chore(deps): update project dependencies --- src/TemporalCollections.Tests/TemporalCollections.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index 3c5ae9a..255f29b 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -8,7 +8,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From b74945bf19c7fe17410750d27484ec23e05301fa Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Tue, 7 Apr 2026 01:15:19 +0200 Subject: [PATCH 14/34] docs(temporal-circular-buffer): improve inline comments --- src/TemporalCollections/Collections/TemporalCircularBuffer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TemporalCollections/Collections/TemporalCircularBuffer.cs b/src/TemporalCollections/Collections/TemporalCircularBuffer.cs index 3027be5..15fbfdc 100644 --- a/src/TemporalCollections/Collections/TemporalCircularBuffer.cs +++ b/src/TemporalCollections/Collections/TemporalCircularBuffer.cs @@ -99,8 +99,8 @@ public override IEnumerable> GetInRange(DateTimeOffset from, Dat long x = item.Timestamp.UtcTicks; if (f <= x && x <= t) list.Add(item); - // Fast-path possibile: items cronologici → si potrebbe interrompere quando x > t. - // Evitiamo early-break per mantenere IterateOrdered semplice e senza effetti collaterali. + // Possible fast-path: chronological items → we could stop when x > t. + // We avoid early-break to keep IterateOrdered simple and free of side effects. }); return list; } From b87f519581993b4c9a4f502c94cb8f7e78d90e8a Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Mon, 13 Apr 2026 21:47:10 +0200 Subject: [PATCH 15/34] chore(deps): update project dependencies --- src/TemporalCollections.Tests/TemporalCollections.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index 255f29b..4e30421 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -12,7 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 25469df5343fc5f6e26d8a0e523e797b114d4519 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 15 May 2026 14:35:09 +0200 Subject: [PATCH 16/34] chore(deps): update project dependencies --- .../TemporalCollections.Tests.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index 4e30421..1ab6861 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -8,11 +8,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive From f424c085484362aae5dcf9269d8d47540dcc3dd4 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 29 May 2026 10:12:22 +0200 Subject: [PATCH 17/34] chore(deps): update project dependencies --- .../TemporalCollections.Tests.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index 1ab6861..6764e15 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -8,11 +8,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive From af7cd242b77c04583a196a3bf05c74093bbce425 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 19 Jun 2026 01:12:42 +0200 Subject: [PATCH 18/34] fix(collections): correct concurrency races, overflow risk, and dead code --- .../Collections/TemporalIntervalTree.cs | 18 ------- .../Collections/TemporalSegmentedArray.cs | 25 +++++++++- .../Collections/TemporalSet.cs | 10 +++- .../Collections/TemporalSlidingWindowSet.cs | 12 +++-- .../Collections/TemporalSortedList.cs | 23 ++++++++- .../Collections/TemporalStack.cs | 48 +++++++++---------- 6 files changed, 85 insertions(+), 51 deletions(-) diff --git a/src/TemporalCollections/Collections/TemporalIntervalTree.cs b/src/TemporalCollections/Collections/TemporalIntervalTree.cs index 4a2a8cf..786750c 100644 --- a/src/TemporalCollections/Collections/TemporalIntervalTree.cs +++ b/src/TemporalCollections/Collections/TemporalIntervalTree.cs @@ -611,24 +611,6 @@ private static void CollectAfter(Node? node, DateTimeOffset time, List= cutoff) - count += CountWithEndAtOrAfter(node.Left, cutoff); - - if (node.End >= cutoff) - count++; - - if (node.Right is not null && node.Right.MaxEnd >= cutoff) - count += CountWithEndAtOrAfter(node.Right, cutoff); - - return count; - } - /// /// Finds the node with the greatest Start <= ; returns null if none. /// diff --git a/src/TemporalCollections/Collections/TemporalSegmentedArray.cs b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs index c7c1e15..1db7797 100644 --- a/src/TemporalCollections/Collections/TemporalSegmentedArray.cs +++ b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs @@ -451,7 +451,7 @@ public override IEnumerable> GetAfter(DateTimeOffset time) return []; long c = time.UtcTicks; var list = new List>(); - int sIdx = FirstSegWithMaxGte(c + 1); + int sIdx = FirstSegWithMaxGt(c); if (sIdx < 0) return []; for (int s = sIdx; s < _segments.Count; s++) @@ -634,6 +634,29 @@ private int FirstSegWithMaxGte(long ticks) return lo; } + /// + /// Finds the first segment whose MaxTicks is strictly greater than . + /// Equivalent to with ticks + 1, but overflow-safe + /// for the corner case where is . + /// + /// Target UTC ticks. + /// Segment index; -1 if no segment satisfies the condition. + private int FirstSegWithMaxGt(long ticks) + { + int lo = 0, hi = _segments.Count - 1; + if (hi < 0) + return -1; + if (_segments[hi].MaxTicks <= ticks) + return -1; + while (lo < hi) + { + int mid = lo + ((hi - lo) >> 1); + if (_segments[mid].MaxTicks <= ticks) + lo = mid + 1; else hi = mid; + } + return lo; + } + /// /// Global lower bound: first position whose timestamp ticks is greater than or equal to . /// diff --git a/src/TemporalCollections/Collections/TemporalSet.cs b/src/TemporalCollections/Collections/TemporalSet.cs index f5c8750..ec688b2 100644 --- a/src/TemporalCollections/Collections/TemporalSet.cs +++ b/src/TemporalCollections/Collections/TemporalSet.cs @@ -71,7 +71,10 @@ public override void RemoveOlderThan(DateTimeOffset cutoff) { if (kv.Value.Timestamp.UtcTicks < c) { - _dict.TryRemove(kv.Key, out _); + // Atomic conditional remove: only delete if the value is still the one observed, + // preventing the removal of a fresh re-Add (with a newer timestamp) that occurred + // between the timestamp check and the TryRemove call. + ((ICollection>>)_dict).Remove(kv); } } } @@ -145,7 +148,10 @@ public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) { long x = kv.Value.Timestamp.UtcTicks; if (f <= x && x <= t) - _dict.TryRemove(kv.Key, out _); + { + // Atomic conditional remove (see RemoveOlderThan). + ((ICollection>>)_dict).Remove(kv); + } } } diff --git a/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs b/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs index f91930d..600550a 100644 --- a/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs +++ b/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs @@ -51,7 +51,9 @@ public void RemoveExpired() { if (kvp.Value.Timestamp.UtcTicks < cutoffTicks) { - _dict.TryRemove(kvp.Key, out _); + // Atomic conditional remove: only delete if the value is still the one observed, + // preventing the removal of a fresh re-Add (with a newer timestamp). + ((ICollection>>)_dict).Remove(kvp); } } } @@ -92,7 +94,8 @@ public override void RemoveOlderThan(DateTimeOffset cutoff) { if (kvp.Value.Timestamp.UtcTicks < c) { - _dict.TryRemove(kvp.Key, out _); + // Atomic conditional remove (see RemoveExpired). + ((ICollection>>)_dict).Remove(kvp); } } } @@ -156,7 +159,10 @@ public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) { long x = kvp.Value.Timestamp.UtcTicks; if (f <= x && x <= t) - _dict.TryRemove(kvp.Key, out _); + { + // Atomic conditional remove (see RemoveExpired). + ((ICollection>>)_dict).Remove(kvp); + } } } diff --git a/src/TemporalCollections/Collections/TemporalSortedList.cs b/src/TemporalCollections/Collections/TemporalSortedList.cs index 5fea3f1..535db60 100644 --- a/src/TemporalCollections/Collections/TemporalSortedList.cs +++ b/src/TemporalCollections/Collections/TemporalSortedList.cs @@ -19,12 +19,31 @@ public class TemporalSortedList : TimeQueryableBase /// /// Adds a new item to the list while preserving chronological order. /// + /// + /// The timestamp is generated INSIDE the lock so that "tick creation" and "list append" + /// are serialized together. This guarantees the fast-path (last item's ticks <= new item's + /// ticks) reflects a real chronological ordering and never degrades to the defensive + /// fallback because of a thread-scheduling + /// reorder between Create and Add. + /// public void Add(T item) { - var temporalItem = TemporalItem.Create(item); lock (_lock) { - // Binary search to find insertion index using the item's comparer (by DateTimeOffset) + var temporalItem = TemporalItem.Create(item); + + // Fast-path: TemporalItem.Create guarantees strictly increasing UTC ticks + // globally per closed generic type, so a new item is virtually always >= the + // last one in this list. Skip the O(log n) binary search and append in O(1). + int n = _items.Count; + if (n == 0 || _items[n - 1].Timestamp.UtcTicks <= temporalItem.Timestamp.UtcTicks) + { + _items.Add(temporalItem); + return; + } + + // Defensive fallback: preserve sorted invariant if a caller-provided item + // (e.g., via future overloads/derivatives) ever arrives out of order. int index = _items.BinarySearch(temporalItem, TemporalItem.TimestampComparer); if (index < 0) index = ~index; _items.Insert(index, temporalItem); diff --git a/src/TemporalCollections/Collections/TemporalStack.cs b/src/TemporalCollections/Collections/TemporalStack.cs index 2badd56..ef4bdf7 100644 --- a/src/TemporalCollections/Collections/TemporalStack.cs +++ b/src/TemporalCollections/Collections/TemporalStack.cs @@ -30,11 +30,20 @@ public int Count /// /// Pushes a new item onto the stack, recording the current timestamp (UTC, monotonic). /// + /// + /// The timestamp is generated INSIDE the lock to atomically pair "tick creation" with + /// "physical append to the list". This guarantees that the physical order in _items + /// matches the chronological order of timestamps even under concurrent pushes, which is + /// the invariant relied upon by , , + /// , and the binary search in . + /// public void Push(T item) { - var temporalItem = TemporalItem.Create(item); lock (_lock) + { + var temporalItem = TemporalItem.Create(item); _items.Add(temporalItem); + } } /// @@ -176,44 +185,33 @@ public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) } /// - /// Gets the most recent item by timestamp, or null if empty. O(n). + /// Gets the most recent item by timestamp, or null if empty. O(1). /// + /// + /// Items are kept in chronological order: appends to the tail, + /// generates strictly increasing UTC ticks, and + /// List.RemoveAll (used by /) + /// preserves the relative order of the items that survive. Hence the last element + /// always carries the largest timestamp. + /// public override TemporalItem? GetLatest() { lock (_lock) { - if (_items.Count == 0) return null; - - TemporalItem best = _items[0]; - long bestTicks = best.Timestamp.UtcTicks; - - for (int i = 1; i < _items.Count; i++) - { - long x = _items[i].Timestamp.UtcTicks; - if (x > bestTicks) { bestTicks = x; best = _items[i]; } - } - return best; + int n = _items.Count; + return n == 0 ? null : _items[n - 1]; } } /// - /// Gets the earliest item by timestamp, or null if empty. O(n). + /// Gets the earliest item by timestamp, or null if empty. O(1). /// + /// See for the ordering invariant rationale. public override TemporalItem? GetEarliest() { lock (_lock) { - if (_items.Count == 0) return null; - - TemporalItem best = _items[0]; - long bestTicks = best.Timestamp.UtcTicks; - - for (int i = 1; i < _items.Count; i++) - { - long x = _items[i].Timestamp.UtcTicks; - if (x < bestTicks) { bestTicks = x; best = _items[i]; } - } - return best; + return _items.Count == 0 ? null : _items[0]; } } From 8aaa619f8d258c88495f92c2f1bee3b225aeeee1 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Thu, 2 Jul 2026 10:42:12 +0200 Subject: [PATCH 19/34] fix(collections): use monotonic sequence tie-breaker and release GC references --- .../Collections/TemporalIntervalTree.cs | 22 ++++++++------- .../Collections/TemporalPriorityQueue.cs | 17 +++++++---- .../Collections/TemporalSegmentedArray.cs | 9 ++++-- .../Models/TemporalItem.cs | 28 +++++++++++++++++-- 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/TemporalCollections/Collections/TemporalIntervalTree.cs b/src/TemporalCollections/Collections/TemporalIntervalTree.cs index 786750c..306f45c 100644 --- a/src/TemporalCollections/Collections/TemporalIntervalTree.cs +++ b/src/TemporalCollections/Collections/TemporalIntervalTree.cs @@ -485,10 +485,10 @@ private static int CountByStartRange(Node? node, DateTimeOffset from, DateTimeOf if (node.End < cutoff) { - node = DeleteRoot(node); - // Ensure that possible replacement at this position is also checked - node = RemoveOlderThanInternal(node, cutoff); - return node; + // Children are already fully pruned by the recursive calls above, + // so no descendant has End < cutoff. DeleteRoot promotes a clean + // node, hence no extra recursion is required here. + return DeleteRoot(node); } Update(node); @@ -507,9 +507,8 @@ private static int CountByStartRange(Node? node, DateTimeOffset from, DateTimeOf if (node.Start >= from && node.Start <= to) { - node = DeleteRoot(node); - node = RemoveByStartRange(node, from, to); - return node; + // Children are already pruned, so the promoted replacement is clean. + return DeleteRoot(node); } Update(node); @@ -523,15 +522,17 @@ private static void QueryCollect(Node? node, DateTimeOffset qs, DateTimeOffset q { if (node is null) return; - // Left subtree can overlap only if its MaxEnd >= qs + // Left subtree can overlap only if its MaxEnd >= qs. if (node.Left is not null && node.Left.MaxEnd >= qs) QueryCollect(node.Left, qs, qe, result); - // Current node overlaps if Start <= qe && End >= qs + // Current node overlaps if Start <= qe && End >= qs. if (node.Start <= qe && node.End >= qs) result.Add(new TemporalItem(node.Value, node.Start)); - // Right subtree may have starts <= qe + // The right subtree only contains starts >= node.Start. If node.Start + // already exceeds qe, every right descendant exceeds qe as well and + // cannot overlap [qs, qe], so we can safely prune it. if (node.Right is not null && node.Start <= qe) QueryCollect(node.Right, qs, qe, result); } @@ -549,6 +550,7 @@ private static void QueryValues(Node? node, DateTimeOffset qs, DateTimeOffset qe if (node.Start <= qe && node.End >= qs) result.Add(node.Value); + // Same right-subtree pruning as in QueryCollect. if (node.Right is not null && node.Start <= qe) QueryValues(node.Right, qs, qe, result); } diff --git a/src/TemporalCollections/Collections/TemporalPriorityQueue.cs b/src/TemporalCollections/Collections/TemporalPriorityQueue.cs index ea3d622..d241888 100644 --- a/src/TemporalCollections/Collections/TemporalPriorityQueue.cs +++ b/src/TemporalCollections/Collections/TemporalPriorityQueue.cs @@ -352,6 +352,13 @@ public override int CountSince(DateTimeOffset from) /// private record QueueItem : TemporalItem, IComparable { + // Monotonic, collision-free sequence number used as a strict-ordering + // tie-breaker. Replaces RuntimeHelpers.GetHashCode (which can collide + // and cause SortedSet to drop distinct items). + private static long _seqCounter; + + private readonly long _seq; + /// Gets the priority of the item. public TPriority Priority { get; } @@ -362,10 +369,12 @@ public QueueItem(TValue value, TPriority priority, DateTimeOffset timestamp) : base(value, timestamp) { Priority = priority; + _seq = Interlocked.Increment(ref _seqCounter); } /// - /// Compares this item to another based on priority, then timestamp, then runtime id for strict ordering. + /// Compares this item to another based on priority, then timestamp, then a + /// monotonic sequence id for a strict weak ordering. /// public int CompareTo(QueueItem? other) { @@ -377,10 +386,8 @@ public int CompareTo(QueueItem? other) c = Timestamp.CompareTo(other.Timestamp); if (c != 0) return c; - // Tie-breaker: ensure strict weak ordering to avoid SortedSet dropping distinct items - int hx = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(this); - int hy = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(other); - return hx.CompareTo(hy); + // Final tie-breaker: monotonic sequence id, collision-free. + return _seq.CompareTo(other._seq); } } diff --git a/src/TemporalCollections/Collections/TemporalSegmentedArray.cs b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs index 1db7797..4f38e97 100644 --- a/src/TemporalCollections/Collections/TemporalSegmentedArray.cs +++ b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs @@ -84,7 +84,11 @@ public void RemoveRangeIndices(int start, int removeCount) if (removeCount <= 0) return; int tail = Count - (start + removeCount); if (tail > 0) Array.Copy(Items, start + removeCount, Items, start, tail); - Count -= removeCount; + int newCount = Count - removeCount; + // Clear the now-stale tail so we don't keep references to removed items + // alive (matters for reference T to allow GC). + Array.Clear(Items, newCount, Count - newCount); + Count = newCount; if (Count == 0) { MinTicks = long.MaxValue; @@ -696,7 +700,8 @@ private void InsertAt(int segIdx, int elemIdx, in TemporalItem item) right.MinTicks = right.Items[0].Timestamp.UtcTicks; right.MaxTicks = right.Items[move - 1].Timestamp.UtcTicks; - // Shrink left segment + // Shrink left segment (clear the moved slots so we don't pin removed values). + Array.Clear(seg.Items, startMove, move); seg.Count = startMove; seg.MinTicks = seg.Items[0].Timestamp.UtcTicks; seg.MaxTicks = seg.Items[seg.Count - 1].Timestamp.UtcTicks; diff --git a/src/TemporalCollections/Models/TemporalItem.cs b/src/TemporalCollections/Models/TemporalItem.cs index 77bdb3c..be95c95 100644 --- a/src/TemporalCollections/Models/TemporalItem.cs +++ b/src/TemporalCollections/Models/TemporalItem.cs @@ -19,6 +19,18 @@ public record TemporalItem(T Value, DateTimeOffset Timestamp) // Initialize with current UtcNow ticks to avoid starting at zero. private static long _lastUtcTicks = DateTimeOffset.UtcNow.UtcTicks; + // Monotonic sequence number assigned to every item produced via Create(). + // Used by TimestampComparer as a deterministic, collision-free tie-break, + // avoiding the (rare but possible) hash-code collisions of RuntimeHelpers.GetHashCode + // that could otherwise cause sorted sets/maps to drop distinct items. + private static long _seq; + + /// + /// Monotonic sequence id assigned by (0 when the item was + /// built via the public constructor without going through ). + /// + internal long Seq { get; init; } + // Legacy shim (UTC) public DateTime TimestampUtc => Timestamp.UtcDateTime; @@ -46,8 +58,9 @@ public static TemporalItem Create(T value) } while (Interlocked.CompareExchange(ref _lastUtcTicks, next, observed) != observed); - // Construct a UTC DateTimeOffset from ticks (offset zero). - return new TemporalItem(value, new DateTimeOffset(next, TimeSpan.Zero)); + // Construct a UTC DateTimeOffset from ticks (offset zero) and stamp a fresh seq. + long seq = Interlocked.Increment(ref _seq); + return new TemporalItem(value, new DateTimeOffset(next, TimeSpan.Zero)) { Seq = seq }; } /// @@ -83,8 +96,17 @@ public int Compare(TemporalItem? x, TemporalItem? y) if (c != 0) return c; } + // Tertiary: monotonic sequence id (collision-free for items built via Create()). + if (x.Seq != 0 || y.Seq != 0) + { + c = x.Seq.CompareTo(y.Seq); + if (c != 0) return c; + } + // Final: runtime identity to keep a strict ordering and avoid - // dropping distinct items from sorted sets/maps. + // dropping distinct items from sorted sets/maps. Used only as a + // last-resort fallback when both items have seq=0 (e.g. items + // built via the public constructor instead of Create()). int hx = RuntimeHelpers.GetHashCode(x); int hy = RuntimeHelpers.GetHashCode(y); return hx.CompareTo(hy); From 8f0334befdcce659e029f5c0dbc26614c18bcf15 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Thu, 6 Aug 2026 09:58:49 +0200 Subject: [PATCH 20/34] chore(deps): update project dependencies --- src/TemporalCollections.Tests/TemporalCollections.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index 6764e15..b84a42c 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -12,7 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive From eb6e9f914f3572c22e8be25b3c8f1d2cbb635191 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Thu, 20 Aug 2026 20:15:36 +0200 Subject: [PATCH 21/34] feat(collections): add binary-search range ops and TryDequeue/TryPeek to TemporalQueue --- README.md | 22 ++- .../Collections/TemporalDictionaryTests.cs | 170 ++++++++++++++++++ .../Collections/TemporalQueueTests.cs | 86 +++++++++ .../TemporalCollections.Tests.csproj | 6 +- .../Collections/TemporalDictionary.cs | 115 ++++++------ .../Collections/TemporalQueue.cs | 30 ++++ .../TemporalCollections.csproj | 6 +- 7 files changed, 372 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index c96328d..fb387af 100644 --- a/README.md +++ b/README.md @@ -60,14 +60,14 @@ At the heart of all collections lies the `TemporalItem` struct: | TemporalSegmentedArray | Thread-safe time-ordered segmented array optimized for append-in-order workloads and retention. | Yes | Sorted by timestamp (global) | Amortized O(1) append in-order, segment-based range queries and cleanup. | | TemporalPriorityQueue | Thread-safe priority queue with timestamped items. Supports priority-based dequeueing and queries. | Yes | Priority order | Priority-based ordering with time queries. | | TemporalIntervalTree | Thread-safe interval tree for timestamped intervals. Efficient overlap queries and interval removals. | Yes | Interval-based | Efficient interval overlap queries and removals. | -| TemporalDictionary | Thread-safe dictionary where each key maps to a timestamped value. Supports add/update, remove, queries.| Yes | Unordered | Key-based access with timestamp tracking and queries. | +| TemporalDictionary | Thread-safe dictionary where each key maps to multiple timestamped values. Supports add, remove, and efficient binary-search range queries.| Yes | Per-key chronological | Key-based access, binary-search range queries and cleanup. | | TemporalMultimap | Thread-safe multimap where each key maps to multiple timestamped values with global time-based queries. | Yes | Per-key chronological | Multiple values per key, per-key range queries and global time view. | | TemporalCircularBuffer | Thread-safe fixed-size circular buffer with timestamped items. Overwrites oldest items on overflow. | Yes | FIFO (circular) | Fixed size, efficient overwriting and time queries. | ## Usage Guidance | Collection Name | When to Use | When Not to Use | |----------------------------------|-------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------| -| TemporalQueue | When you need a thread-safe FIFO queue with time-based retrieval and cleanup. | If you need priority ordering or random access. | +| TemporalQueue | When you need a thread-safe FIFO queue with time-based retrieval and cleanup. Supports `TryDequeue`/`TryPeek` for safe non-throwing access. | If you need priority ordering or random access. | | TemporalStack | When you want a thread-safe LIFO stack with timestamp tracking and time-range queries. | If you require fast arbitrary removal or sorting by timestamp. | | TemporalSet | When you need unique timestamped items with efficient membership checks and time-based removal. | If you require ordering of elements or priority queues. | | TemporalSlidingWindowSet | When you want to automatically retain only recent items within a fixed time window. | If your window size is highly variable or if you need sorted access. | @@ -75,7 +75,7 @@ At the heart of all collections lies the `TemporalItem` struct: | TemporalSegmentedArray | When you ingest events in (mostly) non-decreasing timestamp order and need fast range queries and retention. | If you frequently insert heavily out-of-order, need random removals in the middle, or key lookups. | | TemporalPriorityQueue | When priority-based ordering with timestamp tracking is required for dequeueing. | If you only need FIFO or LIFO semantics without priorities. | | TemporalIntervalTree | When you need efficient interval overlap queries and interval-based time operations. | If your data are single points rather than intervals. | -| TemporalDictionary | When key-based access combined with timestamp tracking and querying is needed. | If ordering or range queries by timestamp are required. | +| TemporalDictionary | When key-based access combined with timestamp tracking and efficient range queries are needed. Uses binary search internally for O(log n) range operations.| If you store a single value per key per time point (use TemporalSortedList) or need global sorted access. | | TemporalMultimap | When each key can have multiple timestamped values and you need per-key queries and/or a global time-ordered view.| If you store a single value per key (use TemporalDictionary) or need ordering by non-time fields.| | TemporalCircularBuffer | When you want a fixed-size buffer that overwrites oldest items with timestamp tracking. | If you need unbounded storage or complex queries. | @@ -145,14 +145,22 @@ var queue = new TemporalQueue(); queue.Enqueue("event-1"); queue.Enqueue("event-2"); -// Peek oldest (does not remove) +// Peek oldest without removing (throws if empty) var oldest = queue.Peek(); Console.WriteLine($"Oldest: {oldest.Value} @ {oldest.Timestamp}"); -// Dequeue oldest (removes) +// Try-peek: safe non-throwing variant +if (queue.TryPeek(out var peeked)) + Console.WriteLine($"Peek: {peeked!.Value} @ {peeked.Timestamp}"); + +// Dequeue oldest (throws if empty) var dequeued = queue.Dequeue(); Console.WriteLine($"Dequeued: {dequeued.Value} @ {dequeued.Timestamp}"); +// Try-dequeue: safe non-throwing variant +while (queue.TryDequeue(out var item)) + Console.WriteLine($"Dequeued: {item!.Value} @ {item.Timestamp}"); + // Query by time range (inclusive) var from = DateTime.UtcNow.AddMinutes(-5); var to = DateTime.UtcNow; @@ -440,7 +448,7 @@ All collections are thread-safe. Locking granularity and common operations (amor | TemporalSegmentedArray | single lock; segmented storage | O(1) amortized (in-order append) | **O(log n + m)** | O(n) | | TemporalPriorityQueue | single lock; SortedSet by (priority,time) | 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) | +| TemporalDictionary | concurrent dict + per-list lock | O(1) avg | **O(log n + m)** | O(log k) | | TemporalMultimap | single lock; per-key ordered lists | O(1) avg | O(n + m log m) | O(n) | | TemporalCircularBuffer | single lock; ring overwrite | O(1) | O(n) | O(n) | @@ -450,6 +458,8 @@ All collections are thread-safe. Locking granularity and common operations (amor - **Deterministic ordering**: query results are returned in ascending timestamp order. - **Snapshot semantics**: methods that return enumerables/lists provide a stable snapshot at call time. - **Thread-safety**: all operations are designed to be thread-safe per collection. +- **Binary-search range queries**: `TemporalDictionary` and other sorted collections use `LowerBound`/`UpperBound` binary search internally, reducing range queries, `RemoveOlderThan`, `RemoveRange`, `CountInRange`, and `CountSince` from O(n) to O(log n + m). +- **Try-pattern**: `TemporalQueue` exposes `TryDequeue`/`TryPeek` for non-throwing access, consistent with `TemporalPriorityQueue` and standard .NET collection conventions. - **Intervals**: for interval-based collections, the Timestamp used by this interface refers to the interval start. ⚠️ **Since v1.1.0, internal timestamp storage has been migrated from DateTime to DateTimeOffset (UTC). Public APIs remain DateTime for backward compatibility, but internal semantics are now strictly UTC-aware.** diff --git a/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs b/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs index 36d41d3..3ba89e4 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs @@ -588,5 +588,175 @@ public void TemporalDictionary_GetNearest_WorksAndTiesPreferLater() Assert.Equal(c.Value.Key, nearCeil.Value.Key); } } + + // ----------------------------------------------------------------------- + // Binary-search optimizations — boundary semantics + // ----------------------------------------------------------------------- + + [Fact] + public void GetBefore_ExactBoundary_IsStrictlyExclusive() + { + var dict = new TemporalDictionary(); + dict.Add("k", 1); + Thread.Sleep(5); + dict.Add("k", 2); + + // Capture the exact timestamp of item "2" + var ts2 = dict.GetLatest()!.Timestamp; + + // GetBefore(ts2) must NOT include item with ts == ts2 + var before = dict.GetBefore(ts2).ToList(); + Assert.DoesNotContain(before, i => i.Value.Value == 2 && i.Timestamp == ts2); + Assert.Contains(before, i => i.Value.Value == 1); + } + + [Fact] + public void GetAfter_ExactBoundary_IsStrictlyExclusive() + { + var dict = new TemporalDictionary(); + dict.Add("k", 1); + Thread.Sleep(5); + dict.Add("k", 2); + + // Capture the exact timestamp of item "1" + var ts1 = dict.GetEarliest()!.Timestamp; + + // GetAfter(ts1) must NOT include item with ts == ts1 + var after = dict.GetAfter(ts1).ToList(); + Assert.DoesNotContain(after, i => i.Value.Value == 1 && i.Timestamp == ts1); + Assert.Contains(after, i => i.Value.Value == 2); + } + + [Fact] + public void CountSince_ExactBoundary_IsInclusive() + { + var dict = new TemporalDictionary(); + dict.Add("k", 1); + Thread.Sleep(5); + dict.Add("k", 2); + Thread.Sleep(5); + dict.Add("k", 3); + + // Get the timestamp of the middle item + var all = dict.GetInRange(DateTime.MinValue, DateTime.MaxValue) + .OrderBy(i => i.Timestamp).ToList(); + var tsMiddle = all[1].Timestamp; + + // CountSince(tsMiddle) must include items with ts >= tsMiddle (items 2 and 3) + int count = dict.CountSince(tsMiddle); + Assert.Equal(2, count); + } + + [Fact] + public void CountInRange_BinarySearch_MatchesGetInRangeCount() + { + var dict = new TemporalDictionary(); + + dict.Add("a", 1); + Thread.Sleep(5); + var tFrom = DateTime.UtcNow; + Thread.Sleep(5); + dict.Add("a", 2); + Thread.Sleep(5); + dict.Add("b", 3); + Thread.Sleep(5); + var tTo = DateTime.UtcNow; + Thread.Sleep(5); + dict.Add("b", 4); + + int expected = dict.GetInRange(tFrom, tTo).Count(); + int actual = dict.CountInRange(tFrom, tTo); + + Assert.Equal(expected, actual); + } + + [Fact] + public void RemoveRange_BinarySearch_LeavesExactlyOutsideElements() + { + var dict = new TemporalDictionary(); + + dict.Add("k", 1); // outside (before) + Thread.Sleep(5); + var tFrom = DateTime.UtcNow; + Thread.Sleep(5); + dict.Add("k", 2); // inside + Thread.Sleep(5); + dict.Add("k", 3); // inside + Thread.Sleep(5); + var tTo = DateTime.UtcNow; + Thread.Sleep(5); + dict.Add("k", 4); // outside (after) + + dict.RemoveRange(tFrom, tTo); + + var remaining = dict.GetInRange(DateTime.MinValue, DateTime.MaxValue) + .Select(i => i.Value.Value).ToList(); + + Assert.Equal(2, remaining.Count); + Assert.Contains(1, remaining); + Assert.Contains(4, remaining); + Assert.DoesNotContain(2, remaining); + Assert.DoesNotContain(3, remaining); + } + + [Fact] + public void GetEarliest_AfterRemoveOlderThan_ReflectsNewMinimum() + { + var dict = new TemporalDictionary(); + + dict.Add("k", 1); + Thread.Sleep(5); + dict.Add("k", 2); + Thread.Sleep(5); + var cutoff = DateTime.UtcNow; + Thread.Sleep(5); + dict.Add("k", 3); + + dict.RemoveOlderThan(cutoff); + + var earliest = dict.GetEarliest(); + Assert.NotNull(earliest); + Assert.Equal(3, earliest!.Value.Value); + } + + [Fact] + public void GetLatest_AfterRemoveRange_ReflectsNewMaximum() + { + var dict = new TemporalDictionary(); + + dict.Add("k", 1); + Thread.Sleep(5); + dict.Add("k", 2); + Thread.Sleep(5); + var tFrom = DateTime.UtcNow; + Thread.Sleep(5); + dict.Add("k", 3); // will be removed + + dict.RemoveRange(tFrom, DateTime.UtcNow.AddMinutes(1)); + + var latest = dict.GetLatest(); + Assert.NotNull(latest); + Assert.Equal(2, latest!.Value.Value); + } + + [Fact] + public void RemoveOlderThan_BinarySearch_ExactCutoffIsKept() + { + // Verify the strict-less-than semantics: items with ts == cutoff must NOT be removed + var dict = new TemporalDictionary(); + + dict.Add("k", 1); + Thread.Sleep(5); + dict.Add("k", 2); + + // Use the exact timestamp of item "2" as the cutoff + var tsCutoff = dict.GetLatest()!.Timestamp; + + dict.RemoveOlderThan(tsCutoff); + + var remaining = dict.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + // Item with ts == tsCutoff must still be present (RemoveOlderThan is strictly <) + Assert.Contains(remaining, i => i.Value.Value == 2); + } } } \ No newline at end of file diff --git a/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs b/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs index 8eb55d2..9d08535 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs @@ -829,5 +829,91 @@ public void TemporalItem_Create_ShouldBeStrictlyMonotonic_UnderHighConcurrency() $"Non-monotonic ticks at index {i}"); } } + + // ----------------------------------------------------------------------- + // TryDequeue / TryPeek + // ----------------------------------------------------------------------- + + [Fact] + public void TryDequeue_ReturnsItem_WhenQueueIsNotEmpty() + { + var queue = new TemporalQueue(); + queue.Enqueue(42); + + bool result = queue.TryDequeue(out var item); + + Assert.True(result); + Assert.NotNull(item); + Assert.Equal(42, item!.Value); + Assert.Equal(0, queue.Count); + } + + [Fact] + public void TryDequeue_ReturnsFalse_WhenQueueIsEmpty() + { + var queue = new TemporalQueue(); + + bool result = queue.TryDequeue(out var item); + + Assert.False(result); + Assert.Null(item); + } + + [Fact] + public void TryDequeue_RespectsTimestampOrder() + { + var queue = new TemporalQueue(); + queue.Enqueue(1); + queue.Enqueue(2); + queue.Enqueue(3); + + queue.TryDequeue(out var first); + queue.TryDequeue(out var second); + queue.TryDequeue(out var third); + + Assert.Equal(1, first!.Value); + Assert.Equal(2, second!.Value); + Assert.Equal(3, third!.Value); + Assert.True(first.Timestamp < second!.Timestamp); + Assert.True(second.Timestamp < third!.Timestamp); + } + + [Fact] + public void TryPeek_ReturnsItem_WithoutRemoving() + { + var queue = new TemporalQueue(); + queue.Enqueue("hello"); + + bool result = queue.TryPeek(out var item); + + Assert.True(result); + Assert.NotNull(item); + Assert.Equal("hello", item!.Value); + Assert.Equal(1, queue.Count); // item must still be in the queue + } + + [Fact] + public void TryPeek_ReturnsFalse_WhenQueueIsEmpty() + { + var queue = new TemporalQueue(); + + bool result = queue.TryPeek(out var item); + + Assert.False(result); + Assert.Null(item); + } + + [Fact] + public void TryPeek_ReturnsSameItemAsDequeue() + { + var queue = new TemporalQueue(); + queue.Enqueue(99); + + queue.TryPeek(out var peeked); + var dequeued = queue.Dequeue(); + + Assert.Equal(peeked!.Value, dequeued.Value); + Assert.Equal(peeked.Timestamp, dequeued.Timestamp); + } } } \ No newline at end of file diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index b84a42c..b0c229e 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -12,12 +12,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/TemporalCollections/Collections/TemporalDictionary.cs b/src/TemporalCollections/Collections/TemporalDictionary.cs index c555e2d..6706498 100644 --- a/src/TemporalCollections/Collections/TemporalDictionary.cs +++ b/src/TemporalCollections/Collections/TemporalDictionary.cs @@ -99,7 +99,11 @@ public override void RemoveOlderThan(DateTimeOffset cutoff) var list = kvp.Value; lock (list) { - list.RemoveAll(item => item.Timestamp.UtcTicks < c); + // Binary search: find first index with ts >= cutoff, then remove [0, idx) in one shot + int idx = LowerBound(list, c); + if (idx > 0) + list.RemoveRange(0, idx); + if (list.Count == 0) _dict.TryRemove(kvp.Key, out _); } @@ -137,14 +141,18 @@ public override IEnumerable>> GetInRange var list = kvp.Value; lock (list) { - foreach (var item in list) + int n = list.Count; + if (n == 0) continue; + + // Binary search: skip elements outside [f, t] in O(log n) + int lo = LowerBound(list, f); + int hi = UpperBound(list, t) - 1; + + for (int i = lo; i <= hi; i++) { - long x = item.Timestamp.UtcTicks; - if (f <= x && x <= t) - { - var pair = new KeyValuePair(kvp.Key, item.Value); - results.Add(new TemporalItem>(pair, item.Timestamp)); - } + var item = list[i]; + var pair = new KeyValuePair(kvp.Key, item.Value); + results.Add(new TemporalItem>(pair, item.Timestamp)); } } } @@ -202,11 +210,11 @@ public override int CountInRange(DateTimeOffset from, DateTimeOffset to) var list = kvp.Value; lock (list) { - count += list.Count(i => - { - long x = i.Timestamp.UtcTicks; - return f <= x && x <= t; - }); + if (list.Count == 0) continue; + // Binary search: count = UpperBound(t) - LowerBound(f) + int lo = LowerBound(list, f); + int hi = UpperBound(list, t); + count += hi - lo; } } return count; @@ -233,11 +241,14 @@ public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) var list = kvp.Value; lock (list) { - list.RemoveAll(i => - { - long x = i.Timestamp.UtcTicks; - return f <= x && x <= t; - }); + if (list.Count == 0) continue; + // The list is sorted ascending: items in [f, t] form a contiguous segment + int lo = LowerBound(list, f); // first index with ts >= f + int hi = UpperBound(list, t); // first index with ts > t + int removeCount = hi - lo; + if (removeCount > 0) + list.RemoveRange(lo, removeCount); + if (list.Count == 0) _dict.TryRemove(kvp.Key, out _); } @@ -260,15 +271,15 @@ public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) var list = kvp.Value; lock (list) { - foreach (var it in list) + if (list.Count == 0) continue; + // List is sorted ascending: last element is the latest + var it = list[^1]; + if (!found || it.Timestamp.UtcTicks > bestTs.UtcTicks) { - if (!found || it.Timestamp.UtcTicks > bestTs.UtcTicks) - { - bestTs = it.Timestamp; - bestKey = kvp.Key; - bestVal = it.Value; - found = true; - } + bestTs = it.Timestamp; + bestKey = kvp.Key; + bestVal = it.Value; + found = true; } } } @@ -296,15 +307,15 @@ public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) var list = kvp.Value; lock (list) { - foreach (var it in list) + if (list.Count == 0) continue; + // List is sorted ascending: first element is the earliest + var it = list[0]; + if (!found || it.Timestamp.UtcTicks < bestTs.UtcTicks) { - if (!found || it.Timestamp.UtcTicks < bestTs.UtcTicks) - { - bestTs = it.Timestamp; - bestKey = kvp.Key; - bestVal = it.Value; - found = true; - } + bestTs = it.Timestamp; + bestKey = kvp.Key; + bestVal = it.Value; + found = true; } } } @@ -330,14 +341,14 @@ public override IEnumerable>> GetBefore( var list = kvp.Value; lock (list) { - foreach (var item in list) + // Binary search: all elements before LowerBound(cutoff) have ts < cutoff + int hi = LowerBound(list, cutoff); + for (int i = 0; i < hi; i++) { - if (item.Timestamp.UtcTicks < cutoff) - { - results.Add(new TemporalItem>( - new KeyValuePair(kvp.Key, item.Value), - item.Timestamp)); - } + var item = list[i]; + results.Add(new TemporalItem>( + new KeyValuePair(kvp.Key, item.Value), + item.Timestamp)); } } } @@ -360,14 +371,14 @@ public override IEnumerable>> GetAfter(D var list = kvp.Value; lock (list) { - foreach (var item in list) + // Binary search: UpperBound(cutoff) is the first index with ts > cutoff + int lo = UpperBound(list, cutoff); + for (int i = lo; i < list.Count; i++) { - if (item.Timestamp.UtcTicks > cutoff) - { - results.Add(new TemporalItem>( - new KeyValuePair(kvp.Key, item.Value), - item.Timestamp)); - } + var item = list[i]; + results.Add(new TemporalItem>( + new KeyValuePair(kvp.Key, item.Value), + item.Timestamp)); } } } @@ -389,11 +400,9 @@ public override int CountSince(DateTimeOffset from) var list = kvp.Value; lock (list) { - for (int i = 0; i < list.Count; i++) - { - if (list[i].Timestamp.UtcTicks >= f) - count++; - } + if (list.Count == 0) continue; + // All elements from LowerBound(f) to end have ts >= f + count += list.Count - LowerBound(list, f); } } diff --git a/src/TemporalCollections/Collections/TemporalQueue.cs b/src/TemporalCollections/Collections/TemporalQueue.cs index 60dafe8..e0039f9 100644 --- a/src/TemporalCollections/Collections/TemporalQueue.cs +++ b/src/TemporalCollections/Collections/TemporalQueue.cs @@ -53,6 +53,21 @@ public TemporalItem Dequeue() } } + /// + /// Attempts to dequeue the oldest item without throwing if the queue is empty. + /// + /// The dequeued item, or null if the queue was empty. + /// true if an item was dequeued; false if the queue was empty. + public bool TryDequeue(out TemporalItem? item) + { + lock (_lock) + { + if (_queue.Count == 0) { item = null; return false; } + item = _queue.Dequeue(); + return true; + } + } + /// /// Returns the item at the front of the queue without removing it. /// @@ -66,6 +81,21 @@ public TemporalItem Peek() } } + /// + /// Attempts to peek at the front item without throwing if the queue is empty. + /// + /// The front item, or null if the queue was empty. + /// true if there is an item; false if the queue was empty. + public bool TryPeek(out TemporalItem? item) + { + lock (_lock) + { + if (_queue.Count == 0) { item = null; return false; } + item = _queue.Peek(); + return true; + } + } + /// /// Retrieves all items whose timestamps are within the specified inclusive time range. /// Snapshot taken under lock for consistent semantics. diff --git a/src/TemporalCollections/TemporalCollections.csproj b/src/TemporalCollections/TemporalCollections.csproj index 35065b3..7492431 100644 --- a/src/TemporalCollections/TemporalCollections.csproj +++ b/src/TemporalCollections/TemporalCollections.csproj @@ -5,14 +5,18 @@ enable enable True - 1.2.1 + 1.3.0 TemporalCollections Francesco Del Re + Copyright © 2025 Francesco Del Re Thread-safe .NET collections with built-in, monotonic timestamps and a unified API for fast range queries, pruning, and temporal analytics. https://github.com/engineering87/TemporalCollections README.md https://github.com/engineering87/TemporalCollections + git LICENSE + temporal;collections;time-series;thread-safe;dictionary;queue;stack;dotnet + Performance improvements: binary-search-based range queries in TemporalDictionary; TryDequeue/TryPeek added to TemporalQueue. From c3c29b6aaf096701e3737869d0875dcd3d328f4e Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 2 Oct 2026 00:23:36 +0200 Subject: [PATCH 22/34] chore(deps): update dependencies --- .../TemporalCollections.Tests.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index b0c229e..985996a 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -8,16 +8,16 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + From 019074abb28ce7d8b011a8839415c00b83be0944 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 4 Oct 2026 22:18:27 +0200 Subject: [PATCH 23/34] feat(extensions): add inclusive directional lookup helpers --- README.md | 6 ++ .../TemporalValueExtensionsTests.cs | 59 +++++++++++++++++++ .../TemporalCollectionExtensions.cs | 44 ++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/README.md b/README.md index fb387af..c1e07b4 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,12 @@ All temporal collections implement the `ITimeQueryable` interface, which prov - **GetNearest(DateTimeOffset time)** Retrieves the item whose timestamp is closest to the specified `time`. +- **GetAtOrBefore(DateTimeOffset time)** + Retrieves the latest item whose timestamp is less than or equal to the specified `time`, or `null` if none exists. + +- **GetAtOrAfter(DateTimeOffset time)** + Retrieves the earliest item whose timestamp is greater than or equal to the specified `time`, or `null` if none exists. + These methods collectively support efficient and thread-safe temporal queries and cleanups, allowing each collection to manage its items according to their timestamps while exposing a unified API. ## 🚀 Getting Started with TemporalCollections diff --git a/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs b/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs index 39b6b47..2b8918f 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs @@ -15,6 +15,18 @@ private static TemporalQueue MakeIntQueue(params int[] values) return q; } + private static TemporalQueue MakeSpacedIntQueue(params int[] values) + { + var q = new TemporalQueue(); + foreach (var v in values) + { + q.Enqueue(v); + Thread.Sleep(2); + } + + return q; + } + [Fact] public void ToValueList_ReturnsAllValuesInChronologicalOrder() { @@ -115,6 +127,53 @@ public void ToReadOnlyValueCollection_MaterializesAndIsReadOnly() Assert.Throws(() => asCollection.Add(42)); } + [Fact] + public void GetAtOrBefore_And_GetAtOrAfter_ReturnInclusiveDirectionalMatches() + { + var source = MakeSpacedIntQueue(10, 20, 30); + var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + + var exactMiddle = items[1].Timestamp; + var betweenMiddleAndLast = exactMiddle.AddMilliseconds(1); + + Assert.Equal(20, source.GetAtOrBefore(exactMiddle)!.Value); + Assert.Equal(20, source.GetAtOrAfter(exactMiddle)!.Value); + Assert.Equal(20, source.GetAtOrBefore(betweenMiddleAndLast)!.Value); + Assert.Equal(30, source.GetAtOrAfter(betweenMiddleAndLast)!.Value); + Assert.Null(source.GetAtOrBefore(items[0].Timestamp.AddMilliseconds(-1))); + Assert.Null(source.GetAtOrAfter(items[^1].Timestamp.AddMilliseconds(1))); + } + + [Fact] + public void GetAtOrBefore_And_GetAtOrAfter_WorkWithDateTimeOverloads() + { + var source = MakeSpacedIntQueue(1, 2, 3); + var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var exactMiddle = items[1].Timestamp.UtcDateTime; + + Assert.Equal(2, source.GetAtOrBefore(exactMiddle)!.Value); + Assert.Equal(2, source.GetAtOrAfter(exactMiddle)!.Value); + } + + [Fact] + public void GetAtOrBefore_And_GetAtOrAfter_WorkForTemporalDictionaryValues() + { + var source = new TemporalDictionary(); + source.Add("a", 100); + Thread.Sleep(2); + source.Add("b", 200); + Thread.Sleep(2); + source.Add("c", 300); + + var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var exactMiddle = items[1].Timestamp; + + Assert.Equal("b", source.GetAtOrBefore(exactMiddle)!.Value.Key); + Assert.Equal(200, source.GetAtOrBefore(exactMiddle)!.Value.Value); + Assert.Equal("b", source.GetAtOrAfter(exactMiddle)!.Value.Key); + Assert.Equal(200, source.GetAtOrAfter(exactMiddle)!.Value.Value); + } + [Fact] public void BucketBy_GroupsIntoSingleDailyBucket_AndAppliesAggregator() { diff --git a/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs b/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs index 883601b..751be3d 100644 --- a/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs +++ b/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs @@ -117,6 +117,50 @@ public static IReadOnlyCollection ToReadOnlyValueCollection(this ITimeQuer .AsReadOnly(); } + /// + /// Retrieves the latest item whose timestamp is less than or equal to the specified time. + /// Returns null when no such item exists. + /// + public static TemporalItem? GetAtOrBefore(this ITimeQueryable source, DateTimeOffset time) + { + ArgumentNullException.ThrowIfNull(source); + + return source.GetInRange(DateTimeOffset.MinValue, time).LastOrDefault(); + } + + /// + /// Retrieves the earliest item whose timestamp is greater than or equal to the specified time. + /// Returns null when no such item exists. + /// + public static TemporalItem? GetAtOrAfter(this ITimeQueryable source, DateTimeOffset time) + { + ArgumentNullException.ThrowIfNull(source); + + return source.GetInRange(time, DateTimeOffset.MaxValue).FirstOrDefault(); + } + + /// + /// Retrieves the latest item whose timestamp is less than or equal to the specified time. + /// Returns null when no such item exists. + /// + public static TemporalItem? GetAtOrBefore(this ITimeQueryable source, DateTime time) + { + ArgumentNullException.ThrowIfNull(source); + + return source.GetInRange(DateTime.MinValue, time).LastOrDefault(); + } + + /// + /// Retrieves the earliest item whose timestamp is greater than or equal to the specified time. + /// Returns null when no such item exists. + /// + public static TemporalItem? GetAtOrAfter(this ITimeQueryable source, DateTime time) + { + ArgumentNullException.ThrowIfNull(source); + + return source.GetInRange(time, DateTime.MaxValue).FirstOrDefault(); + } + /// /// Groups all temporal items into fixed-size time buckets (e.g., per minute, hour, day) /// and applies a custom aggregation function to each bucket. From 6cf33d2b528b2ce831a9ee1f672e606f872b863b Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 4 Oct 2026 22:26:17 +0200 Subject: [PATCH 24/34] feat(extensions): add try directional lookup helpers --- README.md | 6 +++ .../TemporalValueExtensionsTests.cs | 49 +++++++++++++++++++ .../TemporalCollectionExtensions.cs | 44 +++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/README.md b/README.md index c1e07b4..eb7ea74 100644 --- a/README.md +++ b/README.md @@ -124,9 +124,15 @@ All temporal collections implement the `ITimeQueryable` interface, which prov - **GetAtOrBefore(DateTimeOffset time)** Retrieves the latest item whose timestamp is less than or equal to the specified `time`, or `null` if none exists. +- **TryGetAtOrBefore(DateTimeOffset time, out TemporalItem? item)** + Attempts to retrieve the latest item whose timestamp is less than or equal to the specified `time`. + - **GetAtOrAfter(DateTimeOffset time)** Retrieves the earliest item whose timestamp is greater than or equal to the specified `time`, or `null` if none exists. +- **TryGetAtOrAfter(DateTimeOffset time, out TemporalItem? item)** + Attempts to retrieve the earliest item whose timestamp is greater than or equal to the specified `time`. + These methods collectively support efficient and thread-safe temporal queries and cleanups, allowing each collection to manage its items according to their timestamps while exposing a unified API. ## 🚀 Getting Started with TemporalCollections diff --git a/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs b/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs index 2b8918f..2313bde 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs @@ -174,6 +174,55 @@ public void GetAtOrBefore_And_GetAtOrAfter_WorkForTemporalDictionaryValues() Assert.Equal(200, source.GetAtOrAfter(exactMiddle)!.Value.Value); } + [Fact] + public void TryGetAtOrBefore_And_TryGetAtOrAfter_ReturnTrueAndMatchedItem() + { + var source = MakeSpacedIntQueue(10, 20, 30); + var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var exactMiddle = items[1].Timestamp; + var betweenMiddleAndLast = exactMiddle.AddMilliseconds(1); + + var foundBefore = source.TryGetAtOrBefore(betweenMiddleAndLast, out var beforeItem); + var foundAfter = source.TryGetAtOrAfter(betweenMiddleAndLast, out var afterItem); + + Assert.True(foundBefore); + Assert.NotNull(beforeItem); + Assert.Equal(20, beforeItem!.Value); + Assert.True(foundAfter); + Assert.NotNull(afterItem); + Assert.Equal(30, afterItem!.Value); + } + + [Fact] + public void TryGetAtOrBefore_And_TryGetAtOrAfter_ReturnFalseAndNullWhenNoMatchExists() + { + var source = MakeSpacedIntQueue(10, 20, 30); + var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + + var foundBefore = source.TryGetAtOrBefore(items[0].Timestamp.AddMilliseconds(-1), out var beforeItem); + var foundAfter = source.TryGetAtOrAfter(items[^1].Timestamp.AddMilliseconds(1), out var afterItem); + + Assert.False(foundBefore); + Assert.Null(beforeItem); + Assert.False(foundAfter); + Assert.Null(afterItem); + } + + [Fact] + public void TryGetAtOrBefore_And_TryGetAtOrAfter_WorkWithDateTimeOverloads() + { + var source = MakeSpacedIntQueue(1, 2, 3); + var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var exactMiddle = items[1].Timestamp.UtcDateTime; + + Assert.True(source.TryGetAtOrBefore(exactMiddle, out var beforeItem)); + Assert.NotNull(beforeItem); + Assert.Equal(2, beforeItem!.Value); + Assert.True(source.TryGetAtOrAfter(exactMiddle, out var afterItem)); + Assert.NotNull(afterItem); + Assert.Equal(2, afterItem!.Value); + } + [Fact] public void BucketBy_GroupsIntoSingleDailyBucket_AndAppliesAggregator() { diff --git a/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs b/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs index 751be3d..c5856a0 100644 --- a/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs +++ b/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs @@ -128,6 +128,17 @@ public static IReadOnlyCollection ToReadOnlyValueCollection(this ITimeQuer return source.GetInRange(DateTimeOffset.MinValue, time).LastOrDefault(); } + /// + /// Attempts to retrieve the latest item whose timestamp is less than or equal to the specified time. + /// + public static bool TryGetAtOrBefore(this ITimeQueryable source, DateTimeOffset time, out TemporalItem? item) + { + ArgumentNullException.ThrowIfNull(source); + + item = source.GetAtOrBefore(time); + return item is not null; + } + /// /// Retrieves the earliest item whose timestamp is greater than or equal to the specified time. /// Returns null when no such item exists. @@ -139,6 +150,17 @@ public static IReadOnlyCollection ToReadOnlyValueCollection(this ITimeQuer return source.GetInRange(time, DateTimeOffset.MaxValue).FirstOrDefault(); } + /// + /// Attempts to retrieve the earliest item whose timestamp is greater than or equal to the specified time. + /// + public static bool TryGetAtOrAfter(this ITimeQueryable source, DateTimeOffset time, out TemporalItem? item) + { + ArgumentNullException.ThrowIfNull(source); + + item = source.GetAtOrAfter(time); + return item is not null; + } + /// /// Retrieves the latest item whose timestamp is less than or equal to the specified time. /// Returns null when no such item exists. @@ -150,6 +172,17 @@ public static IReadOnlyCollection ToReadOnlyValueCollection(this ITimeQuer return source.GetInRange(DateTime.MinValue, time).LastOrDefault(); } + /// + /// Attempts to retrieve the latest item whose timestamp is less than or equal to the specified time. + /// + public static bool TryGetAtOrBefore(this ITimeQueryable source, DateTime time, out TemporalItem? item) + { + ArgumentNullException.ThrowIfNull(source); + + item = source.GetAtOrBefore(time); + return item is not null; + } + /// /// Retrieves the earliest item whose timestamp is greater than or equal to the specified time. /// Returns null when no such item exists. @@ -161,6 +194,17 @@ public static IReadOnlyCollection ToReadOnlyValueCollection(this ITimeQuer return source.GetInRange(time, DateTime.MaxValue).FirstOrDefault(); } + /// + /// Attempts to retrieve the earliest item whose timestamp is greater than or equal to the specified time. + /// + public static bool TryGetAtOrAfter(this ITimeQueryable source, DateTime time, out TemporalItem? item) + { + ArgumentNullException.ThrowIfNull(source); + + item = source.GetAtOrAfter(time); + return item is not null; + } + /// /// Groups all temporal items into fixed-size time buckets (e.g., per minute, hour, day) /// and applies a custom aggregation function to each bucket. From f7200e28550116057c77add103b2f2595919f382 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 4 Oct 2026 22:31:48 +0200 Subject: [PATCH 25/34] feat(extensions): add temporal count before and after queries --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ce89292..fccde6b 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,4 @@ FodyWeavers.xsd *.msix *.msm *.msp +/.github/copilot-instructions.md From 3cb9866642c73ad487b45e24809789ee3ec6eff3 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sun, 4 Oct 2026 22:31:59 +0200 Subject: [PATCH 26/34] feat(extensions): add temporal count before and after queries --- README.md | 6 +++ .../TemporalValueExtensionsTests.cs | 41 +++++++++++++++++++ .../TemporalCollectionExtensions.cs | 40 ++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/README.md b/README.md index eb7ea74..ef6e6c6 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,12 @@ All temporal collections implement the `ITimeQueryable` interface, which prov - **TryGetAtOrAfter(DateTimeOffset time, out TemporalItem? item)** Attempts to retrieve the earliest item whose timestamp is greater than or equal to the specified `time`. +- **CountBefore(DateTimeOffset time)** + Counts the number of items whose timestamp is strictly before the specified `time`. + +- **CountAfter(DateTimeOffset time)** + Counts the number of items whose timestamp is strictly after the specified `time`. + These methods collectively support efficient and thread-safe temporal queries and cleanups, allowing each collection to manage its items according to their timestamps while exposing a unified API. ## 🚀 Getting Started with TemporalCollections diff --git a/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs b/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs index 2313bde..8ee73a7 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs @@ -223,6 +223,47 @@ public void TryGetAtOrBefore_And_TryGetAtOrAfter_WorkWithDateTimeOverloads() Assert.Equal(2, afterItem!.Value); } + [Fact] + public void CountBefore_And_CountAfter_ReturnExpectedExclusiveCounts() + { + var source = MakeSpacedIntQueue(10, 20, 30); + var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var split = items[1].Timestamp; + + Assert.Equal(1, source.CountBefore(split)); + Assert.Equal(1, source.CountAfter(split)); + Assert.Equal(0, source.CountBefore(items[0].Timestamp)); + Assert.Equal(0, source.CountAfter(items[^1].Timestamp)); + } + + [Fact] + public void CountBefore_And_CountAfter_WorkWithDateTimeOverloads() + { + var source = MakeSpacedIntQueue(1, 2, 3); + var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var split = items[1].Timestamp.UtcDateTime; + + Assert.Equal(1, source.CountBefore(split)); + Assert.Equal(1, source.CountAfter(split)); + } + + [Fact] + public void CountBefore_And_CountAfter_WorkForTemporalDictionaryValues() + { + var source = new TemporalDictionary(); + source.Add("a", 100); + Thread.Sleep(2); + source.Add("b", 200); + Thread.Sleep(2); + source.Add("c", 300); + + var items = source.GetInRange(DateTime.MinValue, DateTime.MaxValue).ToList(); + var split = items[1].Timestamp; + + Assert.Equal(1, source.CountBefore(split)); + Assert.Equal(1, source.CountAfter(split)); + } + [Fact] public void BucketBy_GroupsIntoSingleDailyBucket_AndAppliesAggregator() { diff --git a/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs b/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs index c5856a0..c6a5c85 100644 --- a/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs +++ b/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs @@ -205,6 +205,46 @@ public static bool TryGetAtOrAfter(this ITimeQueryable source, DateTime ti return item is not null; } + /// + /// Counts the number of items whose timestamp is strictly before the specified time. + /// + public static int CountBefore(this ITimeQueryable source, DateTimeOffset time) + { + ArgumentNullException.ThrowIfNull(source); + + return source.GetBefore(time).Count(); + } + + /// + /// Counts the number of items whose timestamp is strictly after the specified time. + /// + public static int CountAfter(this ITimeQueryable source, DateTimeOffset time) + { + ArgumentNullException.ThrowIfNull(source); + + return source.GetAfter(time).Count(); + } + + /// + /// Counts the number of items whose timestamp is strictly before the specified time. + /// + public static int CountBefore(this ITimeQueryable source, DateTime time) + { + ArgumentNullException.ThrowIfNull(source); + + return source.GetBefore(time).Count(); + } + + /// + /// Counts the number of items whose timestamp is strictly after the specified time. + /// + public static int CountAfter(this ITimeQueryable source, DateTime time) + { + ArgumentNullException.ThrowIfNull(source); + + return source.GetAfter(time).Count(); + } + /// /// Groups all temporal items into fixed-size time buckets (e.g., per minute, hour, day) /// and applies a custom aggregation function to each bucket. From b451bce109c0cae28a1e4149b0c484682582d761 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Thu, 22 Oct 2026 11:56:08 +0200 Subject: [PATCH 27/34] feat(collections): add TemporalCache with TTL and sliding expiration --- .../Collections/TemporalCacheTests.cs | 516 ++++++++++++++++++ .../TemporalCollections.Tests.csproj | 6 +- .../Collections/TemporalCache.cs | 440 +++++++++++++++ 3 files changed, 959 insertions(+), 3 deletions(-) create mode 100644 src/TemporalCollections.Tests/Collections/TemporalCacheTests.cs create mode 100644 src/TemporalCollections/Collections/TemporalCache.cs diff --git a/src/TemporalCollections.Tests/Collections/TemporalCacheTests.cs b/src/TemporalCollections.Tests/Collections/TemporalCacheTests.cs new file mode 100644 index 0000000..7375fa4 --- /dev/null +++ b/src/TemporalCollections.Tests/Collections/TemporalCacheTests.cs @@ -0,0 +1,516 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using TemporalCollections.Collections; + +namespace TemporalCollections.Tests.Collections +{ + public class TemporalCacheTests + { + private sealed class ManualTimeProvider : TimeProvider + { + private DateTimeOffset _now; + public ManualTimeProvider(DateTimeOffset start) => _now = start; + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan delta) => _now += delta; + } + + private static (TemporalCache cache, ManualTimeProvider clock) Create( + TimeSpan? ttl = null, bool sliding = false) + { + var clock = new ManualTimeProvider(DateTimeOffset.UtcNow); + var cache = new TemporalCache(ttl ?? TimeSpan.FromMinutes(1), sliding, clock); + return (cache, clock); + } + + [Fact] + public void Constructor_ThrowsOnNonPositiveTtl() + { + Assert.Throws(() => new TemporalCache(TimeSpan.Zero)); + Assert.Throws(() => new TemporalCache(TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void Constructor_SetsProperties() + { + var cache = new TemporalCache(TimeSpan.FromSeconds(5), slidingExpiration: true); + Assert.Equal(TimeSpan.FromSeconds(5), cache.DefaultTtl); + Assert.True(cache.SlidingExpiration); + Assert.Equal(0, cache.Count); + } + + [Fact] + public void Constructor_UsesCustomComparer() + { + var cache = new TemporalCache(TimeSpan.FromMinutes(1), comparer: StringComparer.OrdinalIgnoreCase); + cache.Set("Key", 1); + Assert.True(cache.TryGetValue("KEY", out var v)); + Assert.Equal(1, v); + } + + [Fact] + public void Set_And_TryGetValue_ReturnsValue() + { + var (cache, _) = Create(); + cache.Set("a", 1); + Assert.True(cache.TryGetValue("a", out var v)); + Assert.Equal(1, v); + } + + [Fact] + public void TryGetValue_MissingKey_ReturnsFalse() + { + var (cache, _) = Create(); + Assert.False(cache.TryGetValue("missing", out var v)); + Assert.Equal(default, v); + } + + [Fact] + public void Set_OverwritesExistingValue() + { + var (cache, _) = Create(); + cache.Set("a", 1); + cache.Set("a", 2); + Assert.True(cache.TryGetValue("a", out var v)); + Assert.Equal(2, v); + Assert.Equal(1, cache.Count); + } + + [Fact] + public void Set_ThrowsOnNonPositiveTtl() + { + var (cache, _) = Create(); + Assert.Throws(() => cache.Set("a", 1, TimeSpan.Zero)); + } + + [Fact] + public void Entry_ExpiresAfterDefaultTtl() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(10)); + cache.Set("a", 1); + + clock.Advance(TimeSpan.FromSeconds(9)); + Assert.True(cache.ContainsKey("a")); + + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.False(cache.ContainsKey("a")); + Assert.False(cache.TryGetValue("a", out _)); + Assert.Equal(0, cache.Count); + } + + [Fact] + public void Entry_RespectsPerEntryTtl() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(10)); + cache.Set("short", 1, TimeSpan.FromSeconds(2)); + cache.Set("long", 2); + + clock.Advance(TimeSpan.FromSeconds(3)); + Assert.False(cache.ContainsKey("short")); + Assert.True(cache.ContainsKey("long")); + } + + [Fact] + public void AbsoluteExpiration_ReadDoesNotExtendLifetime() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(10)); + cache.Set("a", 1); + + clock.Advance(TimeSpan.FromSeconds(8)); + Assert.True(cache.TryGetValue("a", out _)); + + clock.Advance(TimeSpan.FromSeconds(3)); + Assert.False(cache.TryGetValue("a", out _)); + } + + [Fact] + public void SlidingExpiration_ReadExtendsLifetime() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(10), sliding: true); + cache.Set("a", 1); + + clock.Advance(TimeSpan.FromSeconds(8)); + Assert.True(cache.TryGetValue("a", out _)); + + clock.Advance(TimeSpan.FromSeconds(8)); + Assert.True(cache.TryGetValue("a", out _)); + + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.False(cache.TryGetValue("a", out _)); + } + + [Fact] + public void SlidingExpiration_ContainsKeyDoesNotExtendLifetime() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(10), sliding: true); + cache.Set("a", 1); + + clock.Advance(TimeSpan.FromSeconds(8)); + Assert.True(cache.ContainsKey("a")); + + clock.Advance(TimeSpan.FromSeconds(3)); + Assert.False(cache.ContainsKey("a")); + } + + [Fact] + public void TryAdd_AddsOnlyWhenAbsent() + { + var (cache, _) = Create(); + Assert.True(cache.TryAdd("a", 1)); + Assert.False(cache.TryAdd("a", 2)); + Assert.True(cache.TryGetValue("a", out var v)); + Assert.Equal(1, v); + } + + [Fact] + public void TryAdd_ReplacesExpiredEntry() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(5)); + cache.TryAdd("a", 1); + clock.Advance(TimeSpan.FromSeconds(6)); + + Assert.True(cache.TryAdd("a", 2)); + Assert.True(cache.TryGetValue("a", out var v)); + Assert.Equal(2, v); + } + + [Fact] + public void TryAdd_ThrowsOnNonPositiveTtl() + { + var (cache, _) = Create(); + Assert.Throws(() => cache.TryAdd("a", 1, TimeSpan.FromSeconds(-1))); + } + + [Fact] + public void GetOrAdd_InvokesFactoryOnlyWhenMissing() + { + var (cache, _) = Create(); + int calls = 0; + + var v1 = cache.GetOrAdd("a", _ => { calls++; return 42; }); + var v2 = cache.GetOrAdd("a", _ => { calls++; return 99; }); + + Assert.Equal(42, v1); + Assert.Equal(42, v2); + Assert.Equal(1, calls); + } + + [Fact] + public void GetOrAdd_RecomputesAfterExpiration() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(5)); + cache.GetOrAdd("a", _ => 1); + clock.Advance(TimeSpan.FromSeconds(5)); + + var v = cache.GetOrAdd("a", _ => 2); + Assert.Equal(2, v); + } + + [Fact] + public void GetOrAdd_ThrowsOnNullFactory() + { + var (cache, _) = Create(); + Assert.Throws(() => cache.GetOrAdd("a", null!)); + } + + [Fact] + public void TryGetItem_ReturnsTemporalItemWithKeyAndValue() + { + var (cache, _) = Create(); + cache.Set("a", 7); + + Assert.True(cache.TryGetItem("a", out var item)); + Assert.NotNull(item); + Assert.Equal("a", item!.Value.Key); + Assert.Equal(7, item.Value.Value); + + Assert.False(cache.TryGetItem("b", out var missing)); + Assert.Null(missing); + } + + [Fact] + public void TryRemove_RemovesLiveEntry() + { + var (cache, _) = Create(); + cache.Set("a", 1); + + Assert.True(cache.TryRemove("a", out var v)); + Assert.Equal(1, v); + Assert.False(cache.ContainsKey("a")); + Assert.False(cache.TryRemove("a", out _)); + } + + [Fact] + public void TryRemove_ExpiredEntry_ReturnsFalse() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(1)); + cache.Set("a", 1); + clock.Advance(TimeSpan.FromSeconds(2)); + Assert.False(cache.TryRemove("a", out _)); + } + + [Fact] + public void GetTimeToLive_ReturnsRemainingTime() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(10)); + cache.Set("a", 1); + clock.Advance(TimeSpan.FromSeconds(4)); + + Assert.Equal(TimeSpan.FromSeconds(6), cache.GetTimeToLive("a")); + Assert.Null(cache.GetTimeToLive("missing")); + + clock.Advance(TimeSpan.FromSeconds(6)); + Assert.Null(cache.GetTimeToLive("a")); + } + + [Fact] + public void Touch_ResetsLifetime() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(10)); + cache.Set("a", 1); + clock.Advance(TimeSpan.FromSeconds(8)); + + Assert.True(cache.Touch("a")); + Assert.Equal(TimeSpan.FromSeconds(10), cache.GetTimeToLive("a")); + + clock.Advance(TimeSpan.FromSeconds(11)); + Assert.False(cache.Touch("a")); + Assert.False(cache.Touch("missing")); + } + + [Fact] + public void RemoveExpired_RemovesOnlyExpiredEntries() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(10)); + cache.Set("a", 1, TimeSpan.FromSeconds(2)); + cache.Set("b", 2, TimeSpan.FromSeconds(3)); + cache.Set("c", 3); + + clock.Advance(TimeSpan.FromSeconds(5)); + Assert.Equal(2, cache.RemoveExpired()); + Assert.Equal(1, cache.Count); + Assert.Equal(new[] { "c" }, cache.Keys); + Assert.Equal(0, cache.RemoveExpired()); + } + + [Fact] + public void Keys_And_Count_ExcludeExpired() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(10)); + cache.Set("a", 1, TimeSpan.FromSeconds(1)); + cache.Set("b", 2); + + clock.Advance(TimeSpan.FromSeconds(2)); + Assert.Equal(1, cache.Count); + Assert.Single(cache.Keys); + Assert.Contains("b", cache.Keys); + } + + [Fact] + public void Clear_RemovesAll() + { + var (cache, _) = Create(); + cache.Set("a", 1); + cache.Set("b", 2); + cache.Clear(); + Assert.Equal(0, cache.Count); + Assert.Null(cache.GetLatest()); + } + + [Fact] + public void GetInRange_ReturnsItemsOrderedByTimestamp() + { + var (cache, _) = Create(); + cache.Set("a", 1); + cache.Set("b", 2); + cache.Set("c", 3); + + var all = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToList(); + Assert.Equal(new[] { "a", "b", "c" }, all.Select(i => i.Value.Key)); + + var bTs = all[1].Timestamp; + var only = cache.GetInRange(bTs, bTs).ToList(); + Assert.Single(only); + Assert.Equal("b", only[0].Value.Key); + } + + [Fact] + public void GetInRange_SwapsReversedBounds() + { + var (cache, _) = Create(); + cache.Set("a", 1); + var ts = cache.GetLatest()!.Timestamp; + + var items = cache.GetInRange(ts.AddSeconds(1), ts.AddSeconds(-1)).ToList(); + Assert.Single(items); + } + + [Fact] + public void GetInRange_DateTimeOverload_Works() + { + var (cache, _) = Create(); + cache.Set("a", 1); + var ts = cache.GetLatest()!.Timestamp.UtcDateTime; + + Assert.Single(cache.GetInRange(ts.AddSeconds(-1), ts.AddSeconds(1))); + } + + [Fact] + public void TimeQueries_ExcludeExpiredEntries() + { + var (cache, clock) = Create(TimeSpan.FromSeconds(10)); + cache.Set("a", 1, TimeSpan.FromSeconds(1)); + cache.Set("b", 2); + clock.Advance(TimeSpan.FromSeconds(2)); + + var all = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToList(); + Assert.Single(all); + Assert.Equal("b", all[0].Value.Key); + Assert.Equal(1, cache.CountInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); + Assert.Equal("b", cache.GetEarliest()!.Value.Key); + } + + [Fact] + public void CountInRange_And_CountSince() + { + var (cache, _) = Create(); + cache.Set("a", 1); + cache.Set("b", 2); + cache.Set("c", 3); + + var items = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToList(); + Assert.Equal(2, cache.CountInRange(items[0].Timestamp, items[1].Timestamp)); + Assert.Equal(2, cache.CountSince(items[1].Timestamp)); + Assert.Equal(0, cache.CountSince(items[2].Timestamp.AddTicks(1))); + } + + [Fact] + public void GetBefore_And_GetAfter_AreExclusive() + { + var (cache, _) = Create(); + cache.Set("a", 1); + cache.Set("b", 2); + cache.Set("c", 3); + + var mid = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ElementAt(1).Timestamp; + + Assert.Equal(new[] { "a" }, cache.GetBefore(mid).Select(i => i.Value.Key)); + Assert.Equal(new[] { "c" }, cache.GetAfter(mid).Select(i => i.Value.Key)); + } + + [Fact] + public void RemoveOlderThan_RemovesStrictlyOlder() + { + var (cache, _) = Create(); + cache.Set("a", 1); + cache.Set("b", 2); + cache.Set("c", 3); + + var mid = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ElementAt(1).Timestamp; + cache.RemoveOlderThan(mid); + + Assert.False(cache.ContainsKey("a")); + Assert.True(cache.ContainsKey("b")); + Assert.True(cache.ContainsKey("c")); + } + + [Fact] + public void RemoveRange_RemovesInclusiveRange() + { + var (cache, _) = Create(); + cache.Set("a", 1); + cache.Set("b", 2); + cache.Set("c", 3); + + var items = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToList(); + cache.RemoveRange(items[1].Timestamp, items[2].Timestamp); + + Assert.Equal(new[] { "a" }, cache.Keys); + } + + [Fact] + public void GetNearest_ReturnsClosestItem() + { + var (cache, _) = Create(); + Assert.Null(cache.GetNearest(DateTimeOffset.UtcNow)); + + cache.Set("a", 1); + cache.Set("b", 2); + var items = cache.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToList(); + + Assert.Equal("a", cache.GetNearest(items[0].Timestamp.AddDays(-1))!.Value.Key); + Assert.Equal("b", cache.GetNearest(items[1].Timestamp.AddDays(1))!.Value.Key); + } + + [Fact] + public void GetEarliest_GetLatest_GetTimeSpan() + { + var (cache, _) = Create(); + Assert.Null(cache.GetEarliest()); + Assert.Null(cache.GetLatest()); + Assert.Equal(TimeSpan.Zero, cache.GetTimeSpan()); + + cache.Set("a", 1); + Assert.Equal(TimeSpan.Zero, cache.GetTimeSpan()); + + cache.Set("b", 2); + var earliest = cache.GetEarliest()!; + var latest = cache.GetLatest()!; + Assert.Equal("a", earliest.Value.Key); + Assert.Equal("b", latest.Value.Key); + Assert.Equal(latest.Timestamp - earliest.Timestamp, cache.GetTimeSpan()); + Assert.True(cache.GetTimeSpan() > TimeSpan.Zero); + } + + [Fact] + public void Set_RefreshesWriteTimestamp() + { + var (cache, _) = Create(); + cache.Set("a", 1); + cache.Set("b", 2); + cache.Set("a", 3); + + Assert.Equal("a", cache.GetLatest()!.Value.Key); + Assert.Equal(3, cache.GetLatest()!.Value.Value); + } + + [Fact] + public void LargeTtl_DoesNotOverflow() + { + var (cache, _) = Create(TimeSpan.MaxValue); + cache.Set("a", 1); + Assert.True(cache.TryGetValue("a", out _)); + Assert.NotNull(cache.GetTimeToLive("a")); + } + + [Fact] + public void ConcurrentOperations_AreThreadSafe() + { + var cache = new TemporalCache(TimeSpan.FromMinutes(1), slidingExpiration: true); + + Parallel.For(0, 10_000, i => + { + int key = i % 100; + cache.Set(key, i); + cache.TryGetValue(key, out _); + cache.GetOrAdd(key + 100, k => k); + if (i % 7 == 0) cache.TryRemove(key, out _); + if (i % 50 == 0) cache.RemoveExpired(); + }); + + Assert.InRange(cache.Count, 100, 200); + for (int k = 100; k < 200; k++) + Assert.True(cache.ContainsKey(k)); + } + + [Fact] + public void ConcurrentGetOrAdd_StoresSingleValue() + { + var cache = new TemporalCache(TimeSpan.FromMinutes(1)); + var results = new System.Collections.Concurrent.ConcurrentBag(); + + Parallel.For(0, 1_000, _ => results.Add(cache.GetOrAdd("k", _ => Guid.NewGuid()))); + + Assert.Single(results.Distinct()); + } + } +} diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index 985996a..f47faed 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -12,12 +12,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/TemporalCollections/Collections/TemporalCache.cs b/src/TemporalCollections/Collections/TemporalCache.cs new file mode 100644 index 0000000..b6a6c20 --- /dev/null +++ b/src/TemporalCollections/Collections/TemporalCache.cs @@ -0,0 +1,440 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using System.Collections.Concurrent; +using TemporalCollections.Abstractions; +using TemporalCollections.Models; + +namespace TemporalCollections.Collections +{ + /// + /// Thread-safe key/value cache where each entry is timestamped at write time and expires + /// after a time-to-live (TTL). Supports absolute expiration (default) or sliding expiration + /// (each successful read extends the entry lifetime). + /// Expired entries are never returned; they are removed lazily on access or eagerly via . + /// Implements over live entries, using the write timestamp. + /// + /// The type of keys (not nullable). + /// The type of cached values. + public class TemporalCache : TimeQueryableBase> + where TKey : notnull + { + private sealed class Entry + { + public Entry(TemporalItem> item, TimeSpan ttl, long expiresAtTicks) + { + Item = item; + Ttl = ttl; + ExpiresAtTicks = expiresAtTicks; + } + + public TemporalItem> Item { get; } + public TimeSpan Ttl { get; } + public long ExpiresAtTicks { get; } + } + + private readonly ConcurrentDictionary _dict; + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new cache. + /// + /// Default time-to-live for entries. Must be positive. + /// If true, each successful read resets the entry lifetime. + /// Clock used for expiration checks. Defaults to . + /// Optional key comparer. + public TemporalCache( + TimeSpan defaultTtl, + bool slidingExpiration = false, + TimeProvider? timeProvider = null, + IEqualityComparer? comparer = null) + { + ValidateTtl(defaultTtl, nameof(defaultTtl)); + DefaultTtl = defaultTtl; + SlidingExpiration = slidingExpiration; + _timeProvider = timeProvider ?? TimeProvider.System; + _dict = new ConcurrentDictionary(comparer ?? EqualityComparer.Default); + } + + /// Default time-to-live applied when no explicit TTL is specified. + public TimeSpan DefaultTtl { get; } + + /// Whether reads extend the entry lifetime. + public bool SlidingExpiration { get; } + + /// Number of live (non-expired) entries. + public int Count + { + get + { + long now = NowTicks; + int count = 0; + foreach (var kvp in _dict) + if (!IsExpired(kvp.Value, now)) count++; + return count; + } + } + + /// Snapshot of keys of live (non-expired) entries. + public IReadOnlyList Keys + { + get + { + long now = NowTicks; + return _dict.Where(kvp => !IsExpired(kvp.Value, now)).Select(kvp => kvp.Key).ToList(); + } + } + + /// Adds or replaces the value for using the default TTL. + public void Set(TKey key, TValue value) => Set(key, value, DefaultTtl); + + /// Adds or replaces the value for using the specified TTL. + public void Set(TKey key, TValue value, TimeSpan ttl) + { + ValidateTtl(ttl, nameof(ttl)); + _dict[key] = CreateEntry(key, value, ttl); + } + + /// + /// Adds the entry only if the key is absent or its current entry is expired. + /// Returns true if added. + /// + public bool TryAdd(TKey key, TValue value) => TryAdd(key, value, DefaultTtl); + + /// + /// Adds the entry with the specified TTL only if the key is absent or its current entry is expired. + /// Returns true if added. + /// + public bool TryAdd(TKey key, TValue value, TimeSpan ttl) + { + ValidateTtl(ttl, nameof(ttl)); + while (true) + { + var entry = CreateEntry(key, value, ttl); + if (_dict.TryAdd(key, entry)) + return true; + + if (!_dict.TryGetValue(key, out var existing)) + continue; + + if (!IsExpired(existing, NowTicks)) + return false; + + if (_dict.TryUpdate(key, entry, existing)) + return true; + } + } + + /// + /// Returns the existing live value for , or adds the value produced by + /// using the default TTL. + /// + public TValue GetOrAdd(TKey key, Func valueFactory) => GetOrAdd(key, valueFactory, DefaultTtl); + + /// + /// Returns the existing live value for , or adds the value produced by + /// using the specified TTL. + /// The factory may be invoked more than once under contention; only one result is stored. + /// + public TValue GetOrAdd(TKey key, Func valueFactory, TimeSpan ttl) + { + ArgumentNullException.ThrowIfNull(valueFactory); + ValidateTtl(ttl, nameof(ttl)); + + while (true) + { + if (TryGetValue(key, out var existing)) + return existing; + + var entry = CreateEntry(key, valueFactory(key), ttl); + + if (_dict.TryAdd(key, entry)) + return entry.Item.Value.Value; + + if (_dict.TryGetValue(key, out var current) && IsExpired(current, NowTicks) + && _dict.TryUpdate(key, entry, current)) + return entry.Item.Value.Value; + } + } + + /// + /// Attempts to get the live value for . + /// With sliding expiration, a successful read extends the entry lifetime. + /// + public bool TryGetValue(TKey key, out TValue value) + { + while (_dict.TryGetValue(key, out var entry)) + { + long now = NowTicks; + if (IsExpired(entry, now)) + { + RemoveIfSame(key, entry); + break; + } + + if (SlidingExpiration) + { + var refreshed = new Entry(entry.Item, entry.Ttl, AddTicks(now, entry.Ttl)); + if (!_dict.TryUpdate(key, refreshed, entry)) + continue; // concurrently modified; re-read + } + + value = entry.Item.Value.Value; + return true; + } + + value = default!; + return false; + } + + /// + /// Attempts to get the live temporal item for without extending its lifetime. + /// + public bool TryGetItem(TKey key, out TemporalItem>? item) + { + if (_dict.TryGetValue(key, out var entry) && !IsExpired(entry, NowTicks)) + { + item = entry.Item; + return true; + } + item = null; + return false; + } + + /// Returns true if a live entry exists for (does not extend lifetime). + public bool ContainsKey(TKey key) + => _dict.TryGetValue(key, out var entry) && !IsExpired(entry, NowTicks); + + /// Removes the entry for . Returns true only if a live entry was removed. + public bool TryRemove(TKey key, out TValue value) + { + if (_dict.TryRemove(key, out var entry) && !IsExpired(entry, NowTicks)) + { + value = entry.Item.Value.Value; + return true; + } + value = default!; + return false; + } + + /// + /// Returns the remaining lifetime of the entry for , or null if missing/expired. + /// + public TimeSpan? GetTimeToLive(TKey key) + { + if (!_dict.TryGetValue(key, out var entry)) return null; + long now = NowTicks; + if (IsExpired(entry, now)) return null; + return TimeSpan.FromTicks(entry.ExpiresAtTicks - now); + } + + /// + /// Resets the lifetime of a live entry to its TTL, starting from now. Returns false if missing/expired. + /// + public bool Touch(TKey key) + { + while (_dict.TryGetValue(key, out var entry)) + { + long now = NowTicks; + if (IsExpired(entry, now)) return false; + var refreshed = new Entry(entry.Item, entry.Ttl, AddTicks(now, entry.Ttl)); + if (_dict.TryUpdate(key, refreshed, entry)) return true; + } + return false; + } + + /// Removes all expired entries. Returns the number of entries removed. + public int RemoveExpired() + { + long now = NowTicks; + int removed = 0; + foreach (var kvp in _dict) + { + if (IsExpired(kvp.Value, now) && RemoveIfSame(kvp.Key, kvp.Value)) + removed++; + } + return removed; + } + + #region ITimeQueryable> implementation + + /// Returns live items written within [from, to] (inclusive), ordered by timestamp. + public override IEnumerable>> GetInRange(DateTimeOffset from, DateTimeOffset to) + { + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + return Snapshot(x => f <= x && x <= t); + } + + /// Removes all entries written strictly before . + public override void RemoveOlderThan(DateTimeOffset cutoff) + { + long c = cutoff.UtcTicks; + RemoveWhere(x => x < c); + } + + /// Counts live items written within [from, to] (inclusive). + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) + { + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + return CountWhere(x => f <= x && x <= t); + } + + /// Removes all entries written within [from, to] (inclusive). + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) + { + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + RemoveWhere(x => f <= x && x <= t); + } + + /// Returns live items written strictly before . + public override IEnumerable>> GetBefore(DateTimeOffset time) + { + long t = time.UtcTicks; + return Snapshot(x => x < t); + } + + /// Returns live items written strictly after . + public override IEnumerable>> GetAfter(DateTimeOffset time) + { + long t = time.UtcTicks; + return Snapshot(x => x > t); + } + + /// Counts live items written at or after . + public override int CountSince(DateTimeOffset from) + { + long f = from.UtcTicks; + return CountWhere(x => x >= f); + } + + /// Returns the live item whose write timestamp is closest to . + public override TemporalItem>? GetNearest(DateTimeOffset time) + { + long t = time.UtcTicks; + long now = NowTicks; + TemporalItem>? best = null; + ulong bestDiff = ulong.MaxValue; + + foreach (var kvp in _dict) + { + if (IsExpired(kvp.Value, now)) continue; + var item = kvp.Value.Item; + long x = item.Timestamp.UtcTicks; + ulong diff = x >= t ? (ulong)(x - t) : (ulong)(t - x); + if (best is null || diff < bestDiff || (diff == bestDiff && x < best.Timestamp.UtcTicks)) + { + best = item; + bestDiff = diff; + } + } + return best; + } + + /// Returns latest - earliest write timestamp among live items, or zero if fewer than two. + public override TimeSpan GetTimeSpan() + { + var earliest = GetEarliest(); + var latest = GetLatest(); + if (earliest is null || latest is null) return TimeSpan.Zero; + var span = latest.Timestamp - earliest.Timestamp; + return span < TimeSpan.Zero ? TimeSpan.Zero : span; + } + + /// Returns the most recently written live item, or null if empty. + public override TemporalItem>? GetLatest() + { + long now = NowTicks; + TemporalItem>? best = null; + foreach (var kvp in _dict) + { + if (IsExpired(kvp.Value, now)) continue; + var item = kvp.Value.Item; + if (best is null || item.Timestamp.UtcTicks > best.Timestamp.UtcTicks) best = item; + } + return best; + } + + /// Returns the earliest written live item, or null if empty. + public override TemporalItem>? GetEarliest() + { + long now = NowTicks; + TemporalItem>? best = null; + foreach (var kvp in _dict) + { + if (IsExpired(kvp.Value, now)) continue; + var item = kvp.Value.Item; + if (best is null || item.Timestamp.UtcTicks < best.Timestamp.UtcTicks) best = item; + } + return best; + } + + /// Removes all entries. + public override void Clear() => _dict.Clear(); + + #endregion + + private long NowTicks => _timeProvider.GetUtcNow().UtcTicks; + + private static bool IsExpired(Entry entry, long nowTicks) => entry.ExpiresAtTicks <= nowTicks; + + private static long AddTicks(long now, TimeSpan ttl) + { + long max = DateTimeOffset.MaxValue.UtcTicks; + return ttl.Ticks >= max - now ? max : now + ttl.Ticks; + } + + private static void ValidateTtl(TimeSpan ttl, string paramName) + { + if (ttl <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(paramName, "TTL must be positive."); + } + + private Entry CreateEntry(TKey key, TValue value, TimeSpan ttl) + { + var item = TemporalItem>.Create(new KeyValuePair(key, value)); + return new Entry(item, ttl, AddTicks(NowTicks, ttl)); + } + + private bool RemoveIfSame(TKey key, Entry entry) + => ((ICollection>)_dict).Remove(new KeyValuePair(key, entry)); + + private List>> Snapshot(Func predicate) + { + long now = NowTicks; + var result = new List>>(); + foreach (var kvp in _dict) + { + if (IsExpired(kvp.Value, now)) continue; + var item = kvp.Value.Item; + if (predicate(item.Timestamp.UtcTicks)) result.Add(item); + } + result.Sort(TemporalItem>.TimestampComparer); + return result; + } + + private int CountWhere(Func predicate) + { + long now = NowTicks; + int count = 0; + foreach (var kvp in _dict) + { + if (!IsExpired(kvp.Value, now) && predicate(kvp.Value.Item.Timestamp.UtcTicks)) count++; + } + return count; + } + + private void RemoveWhere(Func predicate) + { + foreach (var kvp in _dict) + { + if (predicate(kvp.Value.Item.Timestamp.UtcTicks)) + RemoveIfSame(kvp.Key, kvp.Value); + } + } + } +} From 5072fef7cd8986cd6ebf24b33fb3f2e5ac5e3c5f Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Thu, 22 Oct 2026 12:00:11 +0200 Subject: [PATCH 28/34] docs(cache): add TemporalCache benchmarks and README documentation --- README.md | 29 +++++++ .../Benchmarks/TemporalCacheBenchmarks.cs | 85 +++++++++++++++++++ .../Program.cs | 1 + 3 files changed, 115 insertions(+) create mode 100644 src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCacheBenchmarks.cs diff --git a/README.md b/README.md index ef6e6c6..b20a7eb 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ At the heart of all collections lies the `TemporalItem` struct: | TemporalDictionary | Thread-safe dictionary where each key maps to multiple timestamped values. Supports add, remove, and efficient binary-search range queries.| Yes | Per-key chronological | Key-based access, binary-search range queries and cleanup. | | TemporalMultimap | Thread-safe multimap where each key maps to multiple timestamped values with global time-based queries. | Yes | Per-key chronological | Multiple values per key, per-key range queries and global time view. | | TemporalCircularBuffer | Thread-safe fixed-size circular buffer with timestamped items. Overwrites oldest items on overflow. | Yes | FIFO (circular) | Fixed size, efficient overwriting and time queries. | +| TemporalCache | Thread-safe key/value cache with per-entry TTL. Supports absolute or sliding expiration and `TimeProvider`. | Yes | Unordered (by key) | TTL expiration, `GetOrAdd`, `Touch`, time queries over live entries. | ## Usage Guidance | Collection Name | When to Use | When Not to Use | @@ -78,6 +79,7 @@ At the heart of all collections lies the `TemporalItem` struct: | TemporalDictionary | When key-based access combined with timestamp tracking and efficient range queries are needed. Uses binary search internally for O(log n) range operations.| If you store a single value per key per time point (use TemporalSortedList) or need global sorted access. | | TemporalMultimap | When each key can have multiple timestamped values and you need per-key queries and/or a global time-ordered view.| If you store a single value per key (use TemporalDictionary) or need ordering by non-time fields.| | TemporalCircularBuffer | When you want a fixed-size buffer that overwrites oldest items with timestamp tracking. | If you need unbounded storage or complex queries. | +| TemporalCache | When you need a key/value cache whose entries expire after a TTL (absolute or sliding), with time-based queries. | If you need multiple values per key (use TemporalDictionary) or size-based eviction (LRU). | ## ITimeQueryable Interface @@ -418,6 +420,32 @@ Console.WriteLine($"Overlaps: {string.Join(", ", overlapValues)}"); tree.RemoveOlderThan(now.AddMinutes(9)); ``` +**TemporalCache** + +```csharp +using System; +using TemporalCollections.Collections; + +// Entries expire 5 minutes after write; use slidingExpiration: true to extend on read +var cache = new TemporalCache(TimeSpan.FromMinutes(5)); + +cache.Set("user:1", 42); +cache.Set("token", 7, TimeSpan.FromSeconds(30)); // per-entry TTL + +if (cache.TryGetValue("user:1", out var value)) + Console.WriteLine($"user:1 = {value}"); + +var computed = cache.GetOrAdd("user:2", _ => 99); +var ttl = cache.GetTimeToLive("token"); // remaining lifetime or null +cache.Touch("user:1"); // reset lifetime + +// Eager cleanup of expired entries +int removed = cache.RemoveExpired(); + +// Time queries on write timestamps of live entries +var recent = cache.GetAfter(DateTime.UtcNow.AddMinutes(-1)); +``` + ### Common queries via `ITimeQueryable` ```csharp @@ -469,6 +497,7 @@ All collections are thread-safe. Locking granularity and common operations (amor | TemporalDictionary | concurrent dict + per-list lock | O(1) avg | **O(log n + m)** | O(log k) | | TemporalMultimap | single lock; per-key ordered lists | O(1) avg | O(n + m log m) | O(n) | | TemporalCircularBuffer | single lock; ring overwrite | O(1) | O(n) | O(n) | +| TemporalCache | concurrent dict; atomic conditional updates| O(1) avg | O(n + m log m) | O(n) | `n` = items, `m` = matches, `k` = removed. diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCacheBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCacheBenchmarks.cs new file mode 100644 index 0000000..c03191f --- /dev/null +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCacheBenchmarks.cs @@ -0,0 +1,85 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using BenchmarkDotNet.Attributes; +using TemporalCollections.Collections; + +namespace TemporalCollections.PerformanceTests.Benchmarks +{ + [MemoryDiagnoser] + public class TemporalCacheBenchmarks + { + private TemporalCache _cache; + private TemporalCache _slidingCache; + private List _testData; + + [GlobalSetup] + public void Setup() + { + _cache = new TemporalCache(TimeSpan.FromMinutes(10)); + _slidingCache = new TemporalCache(TimeSpan.FromMinutes(10), slidingExpiration: true); + _testData = Enumerable.Range(0, 10000).ToList(); + + foreach (var item in _testData) + { + _cache.Set(item, item); + _slidingCache.Set(item, item); + } + } + + [Benchmark(Description = "Set 10,000 items")] + public void SetItems() + { + foreach (var item in _testData) + { + _cache.Set(item, item); + } + } + + [Benchmark(Description = "TryGetValue 10,000 items (absolute)")] + public int TryGetValueAbsolute() + { + int sum = 0; + foreach (var item in _testData) + { + if (_cache.TryGetValue(item, out var v)) sum += v; + } + return sum; + } + + [Benchmark(Description = "TryGetValue 10,000 items (sliding)")] + public int TryGetValueSliding() + { + int sum = 0; + foreach (var item in _testData) + { + if (_slidingCache.TryGetValue(item, out var v)) sum += v; + } + return sum; + } + + [Benchmark(Description = "GetOrAdd 10,000 items")] + public int GetOrAddItems() + { + int sum = 0; + foreach (var item in _testData) + { + sum += _cache.GetOrAdd(item, k => k); + } + return sum; + } + + [Benchmark(Description = "Query items in last 10 minutes")] + public int QueryRange() + { + var from = DateTime.UtcNow.AddMinutes(-10); + var to = DateTime.UtcNow; + return _cache.GetInRange(from, to).Count(); + } + + [Benchmark(Description = "RemoveExpired scan")] + public int RemoveExpired() + { + return _cache.RemoveExpired(); + } + } +} diff --git a/src/TemporalCollections.PerformanceTests/Program.cs b/src/TemporalCollections.PerformanceTests/Program.cs index 4cd31a2..042f519 100644 --- a/src/TemporalCollections.PerformanceTests/Program.cs +++ b/src/TemporalCollections.PerformanceTests/Program.cs @@ -39,6 +39,7 @@ static void Main(string[] args) typeof(TemporalCircularBufferBenchmarks), typeof(TemporalMultimapBenchmarks), typeof(TemporalSegmentedArrayBenchmarks), + typeof(TemporalCacheBenchmarks), }; var allSummaries = new List(); From 7f07e1508b455bd935b90edc4d1df3dc4bdde706 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Thu, 22 Oct 2026 12:12:11 +0200 Subject: [PATCH 29/34] feat(core): add TimeProvider support across all temporal collections --- README.md | 16 + .../Collections/TimeProviderSupportTests.cs | 465 ++++++++++++++++++ .../Collections/TemporalCache.cs | 2 +- .../Collections/TemporalCircularBuffer.cs | 16 +- .../Collections/TemporalDictionary.cs | 19 +- .../Collections/TemporalMultimap.cs | 19 +- .../Collections/TemporalPriorityQueue.cs | 15 +- .../Collections/TemporalQueue.cs | 19 +- .../Collections/TemporalSegmentedArray.cs | 16 +- .../Collections/TemporalSet.cs | 16 +- .../Collections/TemporalSlidingWindowSet.cs | 20 +- .../Collections/TemporalSortedList.cs | 19 +- .../Collections/TemporalStack.cs | 19 +- .../Models/TemporalItem.cs | 52 +- 14 files changed, 688 insertions(+), 25 deletions(-) create mode 100644 src/TemporalCollections.Tests/Collections/TimeProviderSupportTests.cs diff --git a/README.md b/README.md index b20a7eb..202e355 100644 --- a/README.md +++ b/README.md @@ -476,6 +476,22 @@ This approach ensures: By enforcing this monotonic timestamp ordering, the temporal collections can rely on consistent time-based queries and maintain correct chronological order of items. +## Custom Clock (`TimeProvider`) +All collections accept an optional `TimeProvider` (.NET 8+) used for timestamps and expiration. Existing constructors keep using `TimeProvider.System`, so current code is unaffected. + +```csharp +using Microsoft.Extensions.Time.Testing; // FakeTimeProvider (optional, for tests) + +var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); +var window = new TemporalSlidingWindowSet(TimeSpan.FromMinutes(5), clock); + +window.Add("a"); +clock.Advance(TimeSpan.FromMinutes(6)); // no real waiting +window.RemoveExpired(); // "a" is removed +``` + +`TemporalItem.Create(value, timeProvider)` is also available. Monotonicity is guaranteed per closed generic type and per `TimeProvider` instance. + ## 📈 Performance Benchmarks We provide detailed performance measurements for all temporal data structures, including insertion, range queries, and removal operations. The full benchmark results are available here: [docs/benchmarks/benchmarks.md](docs/benchmarks/benchmarks.md) diff --git a/src/TemporalCollections.Tests/Collections/TimeProviderSupportTests.cs b/src/TemporalCollections.Tests/Collections/TimeProviderSupportTests.cs new file mode 100644 index 0000000..fc3f944 --- /dev/null +++ b/src/TemporalCollections.Tests/Collections/TimeProviderSupportTests.cs @@ -0,0 +1,465 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using TemporalCollections.Collections; +using TemporalCollections.Models; + +namespace TemporalCollections.Tests.Collections +{ + public class TimeProviderSupportTests + { + private sealed class ManualTimeProvider : TimeProvider + { + private DateTimeOffset _now; + public ManualTimeProvider(DateTimeOffset start) => _now = start; + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan delta) => _now += delta; + } + + private static readonly DateTimeOffset Start = new(2020, 1, 1, 12, 0, 0, TimeSpan.Zero); + + // ---------- TemporalItem ---------- + + [Fact] + public void TemporalItem_Create_UsesProvidedClock() + { + var clock = new ManualTimeProvider(Start); + var item = TemporalItem.Create("a", clock); + Assert.Equal(Start, item.Timestamp); + Assert.Equal(TimeSpan.Zero, item.Timestamp.Offset); + } + + [Fact] + public void TemporalItem_Create_IsStrictlyMonotonicWithFrozenClock() + { + var clock = new ManualTimeProvider(Start); + var a = TemporalItem.Create(1, clock); + var b = TemporalItem.Create(2, clock); + var c = TemporalItem.Create(3, clock); + + Assert.Equal(Start.UtcTicks, a.Timestamp.UtcTicks); + Assert.Equal(Start.UtcTicks + 1, b.Timestamp.UtcTicks); + Assert.Equal(Start.UtcTicks + 2, c.Timestamp.UtcTicks); + } + + [Fact] + public void TemporalItem_Create_FollowsClockAdvance() + { + var clock = new ManualTimeProvider(Start); + TemporalItem.Create(1, clock); + clock.Advance(TimeSpan.FromHours(1)); + var later = TemporalItem.Create(2, clock); + Assert.Equal(Start.AddHours(1), later.Timestamp); + } + + [Fact] + public void TemporalItem_Create_ClockGoingBackwards_StaysMonotonic() + { + var clock = new ManualTimeProvider(Start); + var a = TemporalItem.Create(1, clock); + clock.Advance(TimeSpan.FromMinutes(-5)); + var b = TemporalItem.Create(2, clock); + Assert.True(b.Timestamp > a.Timestamp); + } + + [Fact] + public void TemporalItem_Create_PastClock_IsNotAffectedBySystemClock() + { + // System-clock items must not push a custom (past) clock forward. + TemporalItem.Create(0); + var clock = new ManualTimeProvider(Start); + var item = TemporalItem.Create(1, clock); + Assert.Equal(Start, item.Timestamp); + } + + [Fact] + public void TemporalItem_Create_IndependentClocks_DoNotInterfere() + { + var c1 = new ManualTimeProvider(Start); + var c2 = new ManualTimeProvider(Start); + var a = TemporalItem.Create(1, c1); + var b = TemporalItem.Create(2, c2); + Assert.Equal(Start, a.Timestamp); + Assert.Equal(Start, b.Timestamp); + } + + [Fact] + public void TemporalItem_Create_SystemProvider_EquivalentToDefault() + { + var before = DateTimeOffset.UtcNow; + var a = TemporalItem.Create(1, TimeProvider.System); + var b = TemporalItem.Create(2); + Assert.True(a.Timestamp >= before.AddSeconds(-1)); + Assert.True(b.Timestamp > a.Timestamp); + } + + [Fact] + public void TemporalItem_Create_NullProvider_Throws() + { + Assert.Throws(() => TemporalItem.Create(1, null!)); + } + + [Fact] + public void TemporalItem_Create_ConcurrentWithFrozenClock_ProducesUniqueTimestamps() + { + var clock = new ManualTimeProvider(Start); + var bag = new System.Collections.Concurrent.ConcurrentBag(); + Parallel.For(0, 5_000, i => bag.Add(TemporalItem.Create(Guid.Empty, clock).Timestamp.UtcTicks)); + Assert.Equal(5_000, bag.Distinct().Count()); + } + + // ---------- Backward compatibility ---------- + + [Fact] + public void DefaultConstructors_StillUseSystemClock() + { + var before = DateTimeOffset.UtcNow.AddSeconds(-1); + + var q = new TemporalQueue(); q.Enqueue(1); + var s = new TemporalStack(); s.Push(1); + var set = new TemporalSet(); set.Add(1); + var sl = new TemporalSortedList(); sl.Add(1); + var cb = new TemporalCircularBuffer(2); cb.Add(1); + var sa = new TemporalSegmentedArray(); sa.AddValue(1); + var pq = new TemporalPriorityQueue(); pq.Enqueue(1, 1); + var d = new TemporalDictionary(); d.Add(1, 1); + var mm = new TemporalMultimap(); mm.AddValue(1, 1); + var sw = new TemporalSlidingWindowSet(TimeSpan.FromMinutes(1)); sw.Add(1); + + Assert.True(q.GetLatest()!.Timestamp >= before); + Assert.True(s.GetLatest()!.Timestamp >= before); + Assert.True(set.GetLatest()!.Timestamp >= before); + Assert.True(sl.GetLatest()!.Timestamp >= before); + Assert.True(cb.GetLatest()!.Timestamp >= before); + Assert.True(sa.GetLatest()!.Timestamp >= before); + Assert.True(pq.GetLatest()!.Timestamp >= before); + Assert.True(d.GetLatest()!.Timestamp >= before); + Assert.True(mm.GetLatest()!.Timestamp >= before); + Assert.True(sw.GetLatest()!.Timestamp >= before); + } + + [Fact] + public void TemporalSet_ExistingComparerConstructor_StillWorks() + { + var set = new TemporalSet(StringComparer.OrdinalIgnoreCase); + Assert.True(set.Add("A")); + Assert.False(set.Add("a")); + } + + [Fact] + public void Constructors_NullProvider_Throw() + { + Assert.Throws(() => new TemporalQueue(null!)); + Assert.Throws(() => new TemporalStack(null!)); + Assert.Throws(() => new TemporalSet((TimeProvider)null!)); + Assert.Throws(() => new TemporalSortedList(null!)); + Assert.Throws(() => new TemporalCircularBuffer(2, null!)); + Assert.Throws(() => new TemporalSegmentedArray((TimeProvider)null!)); + Assert.Throws(() => new TemporalPriorityQueue(null!)); + Assert.Throws(() => new TemporalDictionary(null!)); + Assert.Throws(() => new TemporalMultimap(null!)); + Assert.Throws(() => new TemporalSlidingWindowSet(TimeSpan.FromMinutes(1), null!)); + } + + // ---------- Clock isolation ---------- + + [Fact] + public void TemporalItem_Create_FutureClock_DoesNotAdvanceSystemClock() + { + var future = new ManualTimeProvider(DateTimeOffset.UtcNow.AddYears(10)); + var f = TemporalItem.Create(1m, future); + + var sys = TemporalItem.Create(2m); + + Assert.True(sys.Timestamp < f.Timestamp); + Assert.True(sys.Timestamp < DateTimeOffset.UtcNow.AddMinutes(1)); + } + + [Fact] + public void Collection_FutureClock_DoesNotAffectSystemClockCollection() + { + var future = new ManualTimeProvider(DateTimeOffset.UtcNow.AddYears(10)); + var fq = new TemporalQueue(future); + var sq = new TemporalQueue(); + + fq.Enqueue(1); + sq.Enqueue(2); + + Assert.True(sq.GetLatest()!.Timestamp < DateTimeOffset.UtcNow.AddMinutes(1)); + Assert.True(fq.GetLatest()!.Timestamp > DateTimeOffset.UtcNow.AddYears(9)); + } + + [Fact] + public void Collections_SharingSameClock_ProduceStrictlyIncreasingTimestamps() + { + var clock = new ManualTimeProvider(Start); + var q1 = new TemporalQueue(clock); + var q2 = new TemporalQueue(clock); + + q1.Enqueue(1); + q2.Enqueue(2); + q1.Enqueue(3); + + var t1 = q1.GetEarliest()!.Timestamp; + var t2 = q2.GetEarliest()!.Timestamp; + var t3 = q1.GetLatest()!.Timestamp; + + Assert.True(t1 < t2); + Assert.True(t2 < t3); + Assert.Equal(Start, t1); + } + + // ---------- Constructor argument validation (new overloads) ---------- + + [Fact] + public void TemporalCircularBuffer_TimeProviderCtor_InvalidCapacity_Throws() + { + var clock = new ManualTimeProvider(Start); + Assert.Throws(() => new TemporalCircularBuffer(0, clock)); + Assert.Throws(() => new TemporalCircularBuffer(-1, clock)); + } + + [Fact] + public void TemporalSegmentedArray_TimeProviderCtor_InvalidCapacity_Throws() + { + var clock = new ManualTimeProvider(Start); + Assert.Throws(() => new TemporalSegmentedArray(clock, segmentCapacity: 1)); + Assert.Throws(() => new TemporalSegmentedArray(clock, segmentCapacity: 0)); + } + + [Fact] + public void TemporalSlidingWindowSet_TimeProviderCtor_InvalidWindow_Throws() + { + var clock = new ManualTimeProvider(Start); + Assert.Throws(() => new TemporalSlidingWindowSet(TimeSpan.Zero, clock)); + Assert.Throws(() => new TemporalSlidingWindowSet(TimeSpan.FromSeconds(-1), clock)); + } + + [Fact] + public void TemporalCache_InvalidTtlWithClock_Throws() + { + var clock = new ManualTimeProvider(Start); + Assert.Throws(() => new TemporalCache(TimeSpan.Zero, timeProvider: clock)); + } + + [Fact] + public void TemporalCache_NullClock_FallsBackToSystem() + { + var cache = new TemporalCache(TimeSpan.FromMinutes(1), timeProvider: null); + cache.Set(1, 1); + Assert.True(cache.GetLatest()!.Timestamp > DateTimeOffset.UtcNow.AddMinutes(-1)); + } + + // ---------- Concurrency with frozen clock ---------- + + [Fact] + public void TemporalStack_ConcurrentPush_FrozenClock_KeepsOrderedUniqueTimestamps() + { + var clock = new ManualTimeProvider(Start); + var s = new TemporalStack(clock); + + Parallel.For(0, 2_000, i => s.Push(i)); + + var items = s.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue) + .Select(i => i.Timestamp.UtcTicks).ToList(); + Assert.Equal(2_000, s.Count); + Assert.Equal(2_000, items.Distinct().Count()); + Assert.Equal(Start, s.GetEarliest()!.Timestamp); + Assert.Equal(Start.AddTicks(1_999), s.GetLatest()!.Timestamp); + } + + [Fact] + public void TemporalSortedList_ConcurrentAdd_FrozenClock_KeepsSortedUniqueTimestamps() + { + var clock = new ManualTimeProvider(Start); + var sl = new TemporalSortedList(clock); + + Parallel.For(0, 2_000, i => sl.Add(i)); + + var ticks = sl.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue) + .Select(i => i.Timestamp.UtcTicks).ToList(); + Assert.Equal(2_000, ticks.Count); + Assert.Equal(ticks.OrderBy(t => t), ticks); + Assert.Equal(2_000, ticks.Distinct().Count()); + } + + [Fact] + public void TemporalDictionary_ConcurrentAdd_FrozenClock_UniqueTimestamps() + { + var clock = new ManualTimeProvider(Start); + var d = new TemporalDictionary(clock); + + Parallel.For(0, 2_000, i => d.Add(i % 10, i)); + + var ticks = d.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue) + .Select(i => i.Timestamp.UtcTicks).ToList(); + Assert.Equal(2_000, ticks.Count); + Assert.Equal(2_000, ticks.Distinct().Count()); + } + + // ---------- Collections use the injected clock ---------- + + [Fact] + public void TemporalQueue_UsesInjectedClock() + { + var clock = new ManualTimeProvider(Start); + var q = new TemporalQueue(clock); + q.Enqueue("a"); + clock.Advance(TimeSpan.FromMinutes(10)); + q.Enqueue("b"); + + Assert.Equal(Start, q.GetEarliest()!.Timestamp); + Assert.Equal(Start.AddMinutes(10), q.GetLatest()!.Timestamp); + Assert.Equal(TimeSpan.FromMinutes(10), q.GetTimeSpan()); + + q.RemoveOlderThan(Start.AddMinutes(5)); + Assert.Equal(1, q.Count); + } + + [Fact] + public void TemporalStack_UsesInjectedClock() + { + var clock = new ManualTimeProvider(Start); + var s = new TemporalStack(clock); + s.Push("a"); + clock.Advance(TimeSpan.FromMinutes(1)); + s.Push("b"); + + Assert.Single(s.GetInRange(Start, Start)); + Assert.Equal(Start.AddMinutes(1), s.GetLatest()!.Timestamp); + } + + [Fact] + public void TemporalSet_UsesInjectedClock() + { + var clock = new ManualTimeProvider(Start); + var set = new TemporalSet(clock, StringComparer.OrdinalIgnoreCase); + set.Add("a"); + clock.Advance(TimeSpan.FromDays(1)); + set.Add("b"); + + Assert.Equal(1, set.CountInRange(Start, Start.AddHours(1))); + Assert.False(set.Add("A")); + } + + [Fact] + public void TemporalSortedList_UsesInjectedClock() + { + var clock = new ManualTimeProvider(Start); + var sl = new TemporalSortedList(clock); + for (int i = 0; i < 5; i++) + { + sl.Add(i); + clock.Advance(TimeSpan.FromSeconds(1)); + } + + Assert.Equal(3, sl.CountInRange(Start.AddSeconds(1), Start.AddSeconds(3))); + Assert.Equal(Start.AddSeconds(2), sl.GetNearest(Start.AddSeconds(2.2))!.Timestamp); + } + + [Fact] + public void TemporalCircularBuffer_UsesInjectedClock() + { + var clock = new ManualTimeProvider(Start); + var cb = new TemporalCircularBuffer(3, clock); + cb.Add(1); + clock.Advance(TimeSpan.FromMinutes(1)); + cb.Add(2); + + Assert.Equal(Start, cb.GetEarliest()!.Timestamp); + Assert.Single(cb.GetAfter(Start)); + } + + [Fact] + public void TemporalSegmentedArray_UsesInjectedClock() + { + var clock = new ManualTimeProvider(Start); + var sa = new TemporalSegmentedArray(clock, segmentCapacity: 2); + for (int i = 0; i < 6; i++) + { + sa.AddValue(i); + clock.Advance(TimeSpan.FromMinutes(1)); + } + + sa.RemoveOlderThan(Start.AddMinutes(3)); + Assert.Equal(3, sa.Count); + Assert.Equal(Start.AddMinutes(3), sa.GetEarliest()!.Timestamp); + } + + [Fact] + public void TemporalPriorityQueue_UsesInjectedClock() + { + var clock = new ManualTimeProvider(Start); + var pq = new TemporalPriorityQueue(clock); + pq.Enqueue("a", 1); + clock.Advance(TimeSpan.FromHours(1)); + pq.Enqueue("b", 2); + + Assert.Equal(1, pq.CountSince(Start.AddMinutes(30))); + Assert.Equal(Start, pq.GetEarliest()!.Timestamp); + } + + [Fact] + public void TemporalDictionary_UsesInjectedClock() + { + var clock = new ManualTimeProvider(Start); + var d = new TemporalDictionary(clock); + d.Add("k", 1); + clock.Advance(TimeSpan.FromMinutes(2)); + d.Add("k", 2); + + var items = d.GetInRange("k", Start, Start.AddMinutes(1)).ToList(); + Assert.Single(items); + Assert.Equal(1, items[0].Value); + } + + [Fact] + public void TemporalMultimap_UsesInjectedClock() + { + var clock = new ManualTimeProvider(Start); + var mm = new TemporalMultimap(clock); + var first = mm.AddValue("k", 1); + clock.Advance(TimeSpan.FromMinutes(2)); + var second = mm.AddValue("k", 2); + + Assert.Equal(Start, first.Timestamp); + Assert.Equal(Start.AddMinutes(2), second.Timestamp); + } + + [Fact] + public void TemporalSlidingWindowSet_RemoveExpired_UsesInjectedClock() + { + var clock = new ManualTimeProvider(Start); + var sw = new TemporalSlidingWindowSet(TimeSpan.FromMinutes(5), clock); + sw.Add("old"); + clock.Advance(TimeSpan.FromMinutes(4)); + sw.Add("new"); + + clock.Advance(TimeSpan.FromMinutes(2)); // old = 6 min, new = 2 min + sw.RemoveExpired(); + + var values = sw.GetItems().Select(i => i.Value).ToList(); + Assert.Equal(new[] { "new" }, values); + + clock.Advance(TimeSpan.FromMinutes(10)); + sw.RemoveExpired(); + Assert.Empty(sw.GetItems()); + } + + [Fact] + public void TemporalCache_WriteTimestampAndExpiration_ShareSameClock() + { + var clock = new ManualTimeProvider(Start); + var cache = new TemporalCache(TimeSpan.FromMinutes(5), timeProvider: clock); + cache.Set("a", 1); + clock.Advance(TimeSpan.FromMinutes(3)); + cache.Set("b", 2); + + Assert.Equal(Start, cache.GetEarliest()!.Timestamp); + Assert.Equal(Start.AddMinutes(3), cache.GetLatest()!.Timestamp); + + clock.Advance(TimeSpan.FromMinutes(3)); // a expired, b alive + var live = cache.GetInRange(Start, Start.AddHours(1)).Select(i => i.Value.Key).ToList(); + Assert.Equal(new[] { "b" }, live); + } + } +} diff --git a/src/TemporalCollections/Collections/TemporalCache.cs b/src/TemporalCollections/Collections/TemporalCache.cs index b6a6c20..038a470 100644 --- a/src/TemporalCollections/Collections/TemporalCache.cs +++ b/src/TemporalCollections/Collections/TemporalCache.cs @@ -396,7 +396,7 @@ private static void ValidateTtl(TimeSpan ttl, string paramName) private Entry CreateEntry(TKey key, TValue value, TimeSpan ttl) { - var item = TemporalItem>.Create(new KeyValuePair(key, value)); + var item = TemporalItem>.Create(new KeyValuePair(key, value), _timeProvider); return new Entry(item, ttl, AddTicks(NowTicks, ttl)); } diff --git a/src/TemporalCollections/Collections/TemporalCircularBuffer.cs b/src/TemporalCollections/Collections/TemporalCircularBuffer.cs index 15fbfdc..d02ece2 100644 --- a/src/TemporalCollections/Collections/TemporalCircularBuffer.cs +++ b/src/TemporalCollections/Collections/TemporalCircularBuffer.cs @@ -15,6 +15,7 @@ public class TemporalCircularBuffer : TimeQueryableBase { private readonly Lock _lock = new(); private readonly TemporalItem[] _buffer; + private readonly TimeProvider _timeProvider; private int _head; private int _count; @@ -25,10 +26,21 @@ public class TemporalCircularBuffer : TimeQueryableBase /// Initializes a new instance of the class with the specified capacity. /// /// Thrown if capacity is not positive. - public TemporalCircularBuffer(int capacity) + public TemporalCircularBuffer(int capacity) : this(capacity, TimeProvider.System) + { + } + + /// + /// Initializes a new instance with the specified capacity and clock used for timestamps. + /// + /// Thrown if capacity is not positive. + public TemporalCircularBuffer(int capacity, TimeProvider timeProvider) { if (capacity <= 0) throw new ArgumentException("Capacity must be positive", nameof(capacity)); + ArgumentNullException.ThrowIfNull(timeProvider); + + _timeProvider = timeProvider; Capacity = capacity; _buffer = new TemporalItem[capacity]; @@ -48,7 +60,7 @@ public int Count /// public void Add(T item) { - var temporalItem = TemporalItem.Create(item); + var temporalItem = TemporalItem.Create(item, _timeProvider); lock (_lock) { diff --git a/src/TemporalCollections/Collections/TemporalDictionary.cs b/src/TemporalCollections/Collections/TemporalDictionary.cs index 6706498..0c96ecf 100644 --- a/src/TemporalCollections/Collections/TemporalDictionary.cs +++ b/src/TemporalCollections/Collections/TemporalDictionary.cs @@ -18,13 +18,30 @@ public class TemporalDictionary : TimeQueryableBase>> _dict = new(); + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new dictionary using the system clock for timestamps. + /// + public TemporalDictionary() : this(TimeProvider.System) + { + } + + /// + /// Initializes a new dictionary using for timestamps. + /// + public TemporalDictionary(TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(timeProvider); + _timeProvider = timeProvider; + } /// /// Adds a new value associated with the specified key, timestamped with the current UTC time. /// public void Add(TKey key, TValue value) { - var temporalItem = TemporalItem.Create(value); + var temporalItem = TemporalItem.Create(value, _timeProvider); // Retry loop: if a concurrent RemoveOlderThan/RemoveRange removes the key // between GetOrAdd and lock(list), the list becomes orphaned. We detect this diff --git a/src/TemporalCollections/Collections/TemporalMultimap.cs b/src/TemporalCollections/Collections/TemporalMultimap.cs index 93fe515..ae30db9 100644 --- a/src/TemporalCollections/Collections/TemporalMultimap.cs +++ b/src/TemporalCollections/Collections/TemporalMultimap.cs @@ -18,6 +18,23 @@ public class TemporalMultimap : TimeQueryableBase>>> _byKey = []; private int _count; private readonly Lock _lock = new(); + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new multimap using the system clock for timestamps. + /// + public TemporalMultimap() : this(TimeProvider.System) + { + } + + /// + /// Initializes a new multimap using for timestamps. + /// + public TemporalMultimap(TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(timeProvider); + _timeProvider = timeProvider; + } /// Returns the total number of items currently stored (across all keys). public int Count @@ -51,7 +68,7 @@ public int KeyCount /// public TemporalItem> AddValue(TKey key, TValue value) { - var item = TemporalItem>.Create(new KeyValuePair(key, value)); + var item = TemporalItem>.Create(new KeyValuePair(key, value), _timeProvider); lock (_lock) { if (!_byKey.TryGetValue(key, out var list)) diff --git a/src/TemporalCollections/Collections/TemporalPriorityQueue.cs b/src/TemporalCollections/Collections/TemporalPriorityQueue.cs index d241888..4a75aba 100644 --- a/src/TemporalCollections/Collections/TemporalPriorityQueue.cs +++ b/src/TemporalCollections/Collections/TemporalPriorityQueue.cs @@ -22,11 +22,22 @@ public class TemporalPriorityQueue : TimeQueryableBase /// Initializes a new instance of the class. /// - public TemporalPriorityQueue() + public TemporalPriorityQueue() : this(TimeProvider.System) { + } + + /// + /// Initializes a new instance using for timestamps. + /// + public TemporalPriorityQueue(TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(timeProvider); + _timeProvider = timeProvider; _set = new SortedSet(new QueueItemComparer()); } + private readonly TimeProvider _timeProvider; + /// /// Gets the number of items currently in the queue. /// @@ -44,7 +55,7 @@ public int Count public void Enqueue(TValue value, TPriority priority) { // TemporalItem.Create ensures strictly increasing DateTimeOffset UTC timestamps. - var ti = TemporalItem.Create(value); + var ti = TemporalItem.Create(value, _timeProvider); lock (_lock) { _set.Add(new QueueItem(ti.Value, priority, ti.Timestamp)); diff --git a/src/TemporalCollections/Collections/TemporalQueue.cs b/src/TemporalCollections/Collections/TemporalQueue.cs index e0039f9..3588488 100644 --- a/src/TemporalCollections/Collections/TemporalQueue.cs +++ b/src/TemporalCollections/Collections/TemporalQueue.cs @@ -16,6 +16,23 @@ public class TemporalQueue : TimeQueryableBase // Oldest -> front ; Newest -> back private readonly Queue> _queue = new(); private readonly Lock _lock = new(); + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new queue using the system clock for timestamps. + /// + public TemporalQueue() : this(TimeProvider.System) + { + } + + /// + /// Initializes a new queue using for timestamps. + /// + public TemporalQueue(TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(timeProvider); + _timeProvider = timeProvider; + } /// /// Gets the total number of items currently in the queue (O(1)). @@ -33,7 +50,7 @@ public int Count /// public void Enqueue(T item) { - var temporalItem = TemporalItem.Create(item); + var temporalItem = TemporalItem.Create(item, _timeProvider); lock (_lock) { _queue.Enqueue(temporalItem); diff --git a/src/TemporalCollections/Collections/TemporalSegmentedArray.cs b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs index 4f38e97..ae1b042 100644 --- a/src/TemporalCollections/Collections/TemporalSegmentedArray.cs +++ b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs @@ -148,13 +148,27 @@ public int UpperExclusive(long ticks) /// /// Maximum number of items per segment; must be > 0. public TemporalSegmentedArray(int segmentCapacity = DefaultSegmentCapacity) + : this(TimeProvider.System, segmentCapacity) { + } + + /// + /// Initializes a new using for timestamps. + /// + /// Clock used to timestamp items. + /// Maximum number of items per segment; must be > 0. + public TemporalSegmentedArray(TimeProvider timeProvider, int segmentCapacity = DefaultSegmentCapacity) + { + ArgumentNullException.ThrowIfNull(timeProvider); if (segmentCapacity < 2) throw new ArgumentOutOfRangeException(nameof(segmentCapacity), "Segment capacity must be at least 2."); + _timeProvider = timeProvider; _segmentCapacity = segmentCapacity; } + private readonly TimeProvider _timeProvider; + /// /// Gets the total number of items currently stored. /// @@ -237,7 +251,7 @@ public void Add(TemporalItem item) /// The created . public TemporalItem AddValue(T value) { - var it = TemporalItem.Create(value); + var it = TemporalItem.Create(value, _timeProvider); Add(it); return it; } diff --git a/src/TemporalCollections/Collections/TemporalSet.cs b/src/TemporalCollections/Collections/TemporalSet.cs index ec688b2..d50bfa6 100644 --- a/src/TemporalCollections/Collections/TemporalSet.cs +++ b/src/TemporalCollections/Collections/TemporalSet.cs @@ -22,16 +22,30 @@ public class TemporalSet : TimeQueryableBase where T : notnull /// /// Equality comparer for items; defaults to . public TemporalSet(IEqualityComparer? comparer = null) + : this(TimeProvider.System, comparer) { + } + + /// + /// Creates a new temporal set using for timestamps. + /// + /// Clock used to timestamp items. + /// Equality comparer for items; defaults to . + public TemporalSet(TimeProvider timeProvider, IEqualityComparer? comparer = null) + { + ArgumentNullException.ThrowIfNull(timeProvider); + _timeProvider = timeProvider; _dict = new ConcurrentDictionary>(comparer ?? EqualityComparer.Default); } + private readonly TimeProvider _timeProvider; + /// /// Adds an item to the set with the current timestamp if not already present. /// public bool Add(T item) { - var temporalItem = TemporalItem.Create(item); // Timestamp is DateTimeOffset UTC (monotonic) + var temporalItem = TemporalItem.Create(item, _timeProvider); // Timestamp return _dict.TryAdd(item, temporalItem); } diff --git a/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs b/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs index 600550a..22ae977 100644 --- a/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs +++ b/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs @@ -22,20 +22,34 @@ public class TemporalSlidingWindowSet : TimeQueryableBase where T : notnul /// /// The sliding time window duration during which items are considered valid. /// Thrown if is zero or negative. - public TemporalSlidingWindowSet(TimeSpan windowSize) + public TemporalSlidingWindowSet(TimeSpan windowSize) : this(windowSize, TimeProvider.System) + { + } + + /// + /// Initializes a new instance with the specified window size and clock used for timestamps and expiration. + /// + /// The sliding time window duration during which items are considered valid. + /// Clock used to timestamp items and evaluate expiration. + /// Thrown if is zero or negative. + public TemporalSlidingWindowSet(TimeSpan windowSize, TimeProvider timeProvider) { if (windowSize <= TimeSpan.Zero) throw new ArgumentException("Window size must be positive.", nameof(windowSize)); + ArgumentNullException.ThrowIfNull(timeProvider); _windowSize = windowSize; + _timeProvider = timeProvider; } + private readonly TimeProvider _timeProvider; + /// /// Attempts to add an item to the set with the current timestamp. /// public bool Add(T item) { - var temporalItem = TemporalItem.Create(item); // Timestamp is DateTimeOffset UTC (monotonic) + var temporalItem = TemporalItem.Create(item, _timeProvider); // Timestamp return _dict.TryAdd(item, temporalItem); } @@ -44,7 +58,7 @@ public bool Add(T item) /// public void RemoveExpired() { - var cutoff = DateTimeOffset.UtcNow - _windowSize; + var cutoff = _timeProvider.GetUtcNow() - _windowSize; long cutoffTicks = cutoff.UtcTicks; foreach (var kvp in _dict) diff --git a/src/TemporalCollections/Collections/TemporalSortedList.cs b/src/TemporalCollections/Collections/TemporalSortedList.cs index 535db60..1a8f255 100644 --- a/src/TemporalCollections/Collections/TemporalSortedList.cs +++ b/src/TemporalCollections/Collections/TemporalSortedList.cs @@ -15,6 +15,23 @@ public class TemporalSortedList : TimeQueryableBase { private readonly List> _items = []; private readonly Lock _lock = new(); + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new sorted list using the system clock for timestamps. + /// + public TemporalSortedList() : this(TimeProvider.System) + { + } + + /// + /// Initializes a new sorted list using for timestamps. + /// + public TemporalSortedList(TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(timeProvider); + _timeProvider = timeProvider; + } /// /// Adds a new item to the list while preserving chronological order. @@ -30,7 +47,7 @@ public void Add(T item) { lock (_lock) { - var temporalItem = TemporalItem.Create(item); + var temporalItem = TemporalItem.Create(item, _timeProvider); // Fast-path: TemporalItem.Create guarantees strictly increasing UTC ticks // globally per closed generic type, so a new item is virtually always >= the diff --git a/src/TemporalCollections/Collections/TemporalStack.cs b/src/TemporalCollections/Collections/TemporalStack.cs index ef4bdf7..fa260b7 100644 --- a/src/TemporalCollections/Collections/TemporalStack.cs +++ b/src/TemporalCollections/Collections/TemporalStack.cs @@ -14,6 +14,23 @@ public class TemporalStack : TimeQueryableBase { private readonly List> _items = []; private readonly Lock _lock = new(); + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new stack using the system clock for timestamps. + /// + public TemporalStack() : this(TimeProvider.System) + { + } + + /// + /// Initializes a new stack using for timestamps. + /// + public TemporalStack(TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(timeProvider); + _timeProvider = timeProvider; + } /// /// Gets the number of items currently in the stack (O(1)). @@ -41,7 +58,7 @@ public void Push(T item) { lock (_lock) { - var temporalItem = TemporalItem.Create(item); + var temporalItem = TemporalItem.Create(item, _timeProvider); _items.Add(temporalItem); } } diff --git a/src/TemporalCollections/Models/TemporalItem.cs b/src/TemporalCollections/Models/TemporalItem.cs index be95c95..bfeb065 100644 --- a/src/TemporalCollections/Models/TemporalItem.cs +++ b/src/TemporalCollections/Models/TemporalItem.cs @@ -45,22 +45,54 @@ public static TemporalItem Create(T value) { // Current wall clock in UTC ticks (offset = 0). long nowTicks = DateTimeOffset.UtcNow.UtcTicks; + long next = NextMonotonic(ref _lastUtcTicks, nowTicks); - long observed; // last observed ticks - long next; // candidate next ticks (>= observed + 1) + // Construct a UTC DateTimeOffset from ticks (offset zero) and stamp a fresh seq. + long seq = Interlocked.Increment(ref _seq); + return new TemporalItem(value, new DateTimeOffset(next, TimeSpan.Zero)) { Seq = seq }; + } + + // Per-clock monotonic state for custom TimeProviders, so that a fake/manual clock + // is not pushed forward by timestamps produced with the system clock (and vice versa). + private static readonly ConditionalWeakTable> _customClocks = new(); + + /// + /// Creates a new item stamped with a strictly increasing UTC timestamp obtained from + /// . When is + /// this is equivalent to . + /// Monotonicity is guaranteed per closed generic type and per instance. + /// + /// Value to wrap. + /// Clock used to obtain the current UTC time. + /// New with monotonic UTC timestamp. + public static TemporalItem Create(T value, TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(timeProvider); - // Ensure strictly increasing ticks using an atomic CAS loop: - // next = max(nowTicks, observed + 1) + if (ReferenceEquals(timeProvider, TimeProvider.System)) + return Create(value); + + long nowTicks = timeProvider.GetUtcNow().UtcTicks; + var state = _customClocks.GetValue(timeProvider, _ => new StrongBox(long.MinValue)); + long next = NextMonotonic(ref state.Value, nowTicks); + + long seq = Interlocked.Increment(ref _seq); + return new TemporalItem(value, new DateTimeOffset(next, TimeSpan.Zero)) { Seq = seq }; + } + + // Ensure strictly increasing ticks using an atomic CAS loop: + // next = max(nowTicks, observed + 1) + private static long NextMonotonic(ref long last, long nowTicks) + { + long observed; + long next; do { - observed = Volatile.Read(ref _lastUtcTicks); + observed = Volatile.Read(ref last); next = nowTicks <= observed ? observed + 1 : nowTicks; } - while (Interlocked.CompareExchange(ref _lastUtcTicks, next, observed) != observed); - - // Construct a UTC DateTimeOffset from ticks (offset zero) and stamp a fresh seq. - long seq = Interlocked.Increment(ref _seq); - return new TemporalItem(value, new DateTimeOffset(next, TimeSpan.Zero)) { Seq = seq }; + while (Interlocked.CompareExchange(ref last, next, observed) != observed); + return next; } /// From c2d476f038551de049fda49edf8be0ea9b3914ad Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 23 Oct 2026 16:23:05 +0200 Subject: [PATCH 30/34] perf(multimap): use k-way merge for global range queries --- docs/benchmarks/benchmarks.md | 71 +++++++++++++- .../Benchmarks/TemporalCacheBenchmarks.cs | 10 +- .../TemporalCircularBufferBenchmarks.cs | 8 +- .../TemporalDictionaryBenchmarks.cs | 6 +- .../TemporalIntervalTreeBenchmarks.cs | 6 +- .../Benchmarks/TemporalMultimapBenchmarks.cs | 2 +- .../TemporalPriorityQueueBenchmarks.cs | 6 +- .../Benchmarks/TemporalQueueBenchmarks.cs | 6 +- .../TemporalSegmentedArrayBenchmarks.cs | 2 +- .../Benchmarks/TemporalSetBenchmarks.cs | 6 +- .../TemporalSlidingWindowSetBenchmarks.cs | 6 +- .../TemporalSortedListBenchmarks.cs | 6 +- .../Benchmarks/TemporalStackBenchmarks.cs | 6 +- .../Program.cs | 67 +++++++------ .../Collections/TemporalMultimap.cs | 97 +++++++++++++++---- 15 files changed, 219 insertions(+), 86 deletions(-) diff --git a/docs/benchmarks/benchmarks.md b/docs/benchmarks/benchmarks.md index c7460b9..47d78cc 100644 --- a/docs/benchmarks/benchmarks.md +++ b/docs/benchmarks/benchmarks.md @@ -1,4 +1,73 @@ -# Performance Testing Summary (Updated – 2025-08-19) +# Performance Testing Summary + +## Latest Run (Fast job) + +### Environment +- **CPU**: Intel Core Ultra 7 155H, 22 logical / 16 physical cores +- **OS**: Windows 11 25H2 +- **Runtime**: .NET 9.0.21, X64 RyuJIT x86-64-v3 +- **BenchmarkDotNet**: v0.15.8, `Fast` job (`LaunchCount=1`, `WarmupCount=3`, `IterationCount=10`) +- **Dataset sizes**: 5,000 items per structure; `TemporalSegmentedArray` 20,000 items (`SegmentCapacity` = 256, 1024); `TemporalMultimap` 500 values per key (`KeyCount` = 10, 100) + +> ⚠️ These numbers are **not directly comparable** with the 2025 results below (dataset size halved, different job and hardware). +> Run `dotnet run -c Release -- --full` for the full BenchmarkDotNet default job. + +All times are **mean in microseconds (µs)**. + +### Core structures (5,000 items) + +| Structure | AddItems (µs) | QueryRange (µs) | RemoveOlderThan (µs) | +|---|---:|---:|---:| +| TemporalSet | 271.5 | 0.30 | 0.08 | +| TemporalSlidingWindowSet | 292.9 | 0.32 | 0.09 | +| TemporalCircularBuffer | 287.5 | 0.08 | 0.04 | +| TemporalStack | 455.7 | 0.07 | 0.04 | +| TemporalSortedList | 477.6 | 0.07 | 0.04 | +| TemporalIntervalTree | 530.2 | 0.07 | 0.04 | +| TemporalDictionary | 611.7 | 0.11 | 0.08 | +| TemporalPriorityQueue | 5,085.7 | 0.14 | 0.06 | + +**TemporalQueue**: `Enqueue` 0.08 µs, `Dequeue` 0.07 µs, `Peek` 0.02 µs, `RemoveOlderThan` 0.01 µs, `GetInRange_Count` 20.5 µs. + +### TemporalCache (5,000 items) + +| Method | Mean (µs) | Allocated | +|---|---:|---:| +| RemoveExpired scan | 47.0 | 64 B | +| TryGetValue (absolute) | 128.7 | 0 B | +| GetOrAdd | 134.7 | 0 B | +| QueryRange (last 10 minutes) | 196.4 | 131,584 B | +| TryGetValue (sliding) | 265.5 | 200,000 B | +| Set | 484.7 | 440,000 B | + +### TemporalSegmentedArray (20,000 items, range across `SegmentCapacity` 256 / 1024) + +| Method | Mean (µs) | +|---|---:| +| Clear / Extremes / CountInRange / GetNearest / RemoveRange | 0.8 – 4.5 | +| TrimExcess / ToArray | 11 – 16 | +| GetBefore / GetAfter (midpoint) | 51 – 54 | +| AddSorted (sorted input) | 129 – 134 | +| Add pre-built (sorted) | 441 – 480 | +| AddValue | 1,496 – 1,546 | +| AddRange / Add pre-built (**unsorted** input) | 4,825 – 5,133 | + +> Unsorted input is **~35× slower** than `AddSorted`: prefer sorted batches when possible. + +### TemporalMultimap (indicative only) + +Results show high variance (up to ±60% StdDev on global queries) because of per-iteration setup; they are **not reported as reference values**. +Order of magnitude: per-key removals/queries 1–30 µs, global counts/removals 4–65 µs, inserts 0.2–5 ms, global range/nearest queries 0.4–11 ms. + +### Takeaways +- `QueryRange` and `RemoveOlderThan` remain **sub-microsecond** across all core structures. +- `TemporalPriorityQueue` is still the most expensive on inserts (~10× the other structures). +- `TemporalCache` sliding expiration costs ~2× absolute expiration and allocates on every read. +- Extrapolating to 10,000 items (linear scaling, indicative only), `TemporalDictionary`, `TemporalIntervalTree` and `TemporalSortedList` inserts appear faster than in the 2025 run. + +--- + +# Historical Results (2025-08-19) ### Overview The following benchmarks summarize the performance of different temporal data structures across core operations: **AddItems**, **QueryRange**, and **RemoveOlderThan**. diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCacheBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCacheBenchmarks.cs index c03191f..a2606f8 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCacheBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCacheBenchmarks.cs @@ -17,7 +17,7 @@ public void Setup() { _cache = new TemporalCache(TimeSpan.FromMinutes(10)); _slidingCache = new TemporalCache(TimeSpan.FromMinutes(10), slidingExpiration: true); - _testData = Enumerable.Range(0, 10000).ToList(); + _testData = Enumerable.Range(0, 5000).ToList(); foreach (var item in _testData) { @@ -26,7 +26,7 @@ public void Setup() } } - [Benchmark(Description = "Set 10,000 items")] + [Benchmark(Description = "Set 5,000 items")] public void SetItems() { foreach (var item in _testData) @@ -35,7 +35,7 @@ public void SetItems() } } - [Benchmark(Description = "TryGetValue 10,000 items (absolute)")] + [Benchmark(Description = "TryGetValue 5,000 items (absolute)")] public int TryGetValueAbsolute() { int sum = 0; @@ -46,7 +46,7 @@ public int TryGetValueAbsolute() return sum; } - [Benchmark(Description = "TryGetValue 10,000 items (sliding)")] + [Benchmark(Description = "TryGetValue 5,000 items (sliding)")] public int TryGetValueSliding() { int sum = 0; @@ -57,7 +57,7 @@ public int TryGetValueSliding() return sum; } - [Benchmark(Description = "GetOrAdd 10,000 items")] + [Benchmark(Description = "GetOrAdd 5,000 items")] public int GetOrAddItems() { int sum = 0; diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCircularBufferBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCircularBufferBenchmarks.cs index c891c5e..301e715 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCircularBufferBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalCircularBufferBenchmarks.cs @@ -1,4 +1,4 @@ -// (c) 2025 Francesco Del Re +// (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using BenchmarkDotNet.Attributes; using TemporalCollections.Collections; @@ -14,11 +14,11 @@ public class TemporalCircularBufferBenchmarks [GlobalSetup] public void Setup() { - _buffer = new TemporalCircularBuffer(capacity: 20000); - _testData = Enumerable.Range(0, 10000).ToList(); + _buffer = new TemporalCircularBuffer(capacity: 10000); + _testData = Enumerable.Range(0, 5000).ToList(); } - [Benchmark(Description = "Add 10,000 items")] + [Benchmark(Description = "Add 5,000 items")] public void AddItems() { foreach (var item in _testData) diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalDictionaryBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalDictionaryBenchmarks.cs index 240fb1f..a2a208a 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalDictionaryBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalDictionaryBenchmarks.cs @@ -1,4 +1,4 @@ -// (c) 2025 Francesco Del Re +// (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using BenchmarkDotNet.Attributes; using TemporalCollections.Collections; @@ -15,10 +15,10 @@ public class TemporalDictionaryBenchmarks public void Setup() { _dict = new TemporalDictionary(); - _testData = Enumerable.Range(0, 10000).ToList(); + _testData = Enumerable.Range(0, 5000).ToList(); } - [Benchmark(Description = "Add 10,000 items")] + [Benchmark(Description = "Add 5,000 items")] public void AddItems() { foreach (var item in _testData) diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalIntervalTreeBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalIntervalTreeBenchmarks.cs index ffc76b4..1cf2ae0 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalIntervalTreeBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalIntervalTreeBenchmarks.cs @@ -1,4 +1,4 @@ -// (c) 2025 Francesco Del Re +// (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using BenchmarkDotNet.Attributes; using TemporalCollections.Collections; @@ -16,7 +16,7 @@ public class TemporalIntervalTreeBenchmarks public void Setup() { _tree = new TemporalIntervalTree(); - _testData = Enumerable.Range(0, 10000).ToList(); + _testData = Enumerable.Range(0, 5000).ToList(); var now = DateTime.UtcNow; _intervals = new List<(DateTime, DateTime)>(_testData.Count); @@ -29,7 +29,7 @@ public void Setup() } } - [Benchmark(Description = "Insert 10,000 intervals")] + [Benchmark(Description = "Insert 5,000 intervals")] public void AddItems() { for (int i = 0; i < _testData.Count; i++) diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalMultimapBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalMultimapBenchmarks.cs index 0660a63..d1625fb 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalMultimapBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalMultimapBenchmarks.cs @@ -30,7 +30,7 @@ public class TemporalMultimapBenchmarks public int KeyCount { get; set; } /// Number of values per key. - [Params(1_000)] + [Params(500)] public int ValuesPerKey { get; set; } // ---------- Prepared data ---------- diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalPriorityQueueBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalPriorityQueueBenchmarks.cs index a7fbdc4..6800136 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalPriorityQueueBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalPriorityQueueBenchmarks.cs @@ -1,4 +1,4 @@ -// (c) 2025 Francesco Del Re +// (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using BenchmarkDotNet.Attributes; using TemporalCollections.Collections; @@ -16,13 +16,13 @@ public class TemporalPriorityQueueBenchmarks public void Setup() { _queue = new TemporalPriorityQueue(); - _testData = Enumerable.Range(0, 10000).ToList(); + _testData = Enumerable.Range(0, 5000).ToList(); var rand = new Random(42); _priorities = _testData.Select(_ => rand.Next(0, 1000)).ToList(); } - [Benchmark(Description = "Enqueue 10,000 items")] + [Benchmark(Description = "Enqueue 5,000 items")] public void AddItems() { for (int i = 0; i < _testData.Count; i++) diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalQueueBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalQueueBenchmarks.cs index 8e93799..38c0351 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalQueueBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalQueueBenchmarks.cs @@ -1,4 +1,4 @@ -// (c) 2025 Francesco Del Re +// (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using BenchmarkDotNet.Attributes; using TemporalCollections.Collections; @@ -19,10 +19,10 @@ public class TemporalQueueBenchmarks public void Setup() { _queue = new TemporalQueue(); - _values = Enumerable.Range(0, 10000).ToArray(); + _values = Enumerable.Range(0, 5000).ToArray(); _now = DateTime.UtcNow; - // Pre-fill the queue with 10k items with timestamps spread in the past 10000 seconds + // Pre-fill the queue with 5k items with timestamps spread in the past 10000 seconds for (int i = 0; i < _values.Length; i++) { var item = new TemporalItem(_values[i], _now.AddSeconds(-_values.Length + i)); diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSegmentedArrayBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSegmentedArrayBenchmarks.cs index 7e5d39a..b6cab48 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSegmentedArrayBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSegmentedArrayBenchmarks.cs @@ -26,7 +26,7 @@ public class TemporalSegmentedArrayBenchmarks // ---------- Parameters ---------- /// Total number of items to insert for the test datasets. - [Params(50_000)] + [Params(20_000)] public int ItemCount { get; set; } /// Per-segment capacity used by TemporalSegmentedArray. diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSetBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSetBenchmarks.cs index 5e22b40..1c2943e 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSetBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSetBenchmarks.cs @@ -1,4 +1,4 @@ -// (c) 2025 Francesco Del Re +// (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using BenchmarkDotNet.Attributes; using TemporalCollections.Collections; @@ -15,10 +15,10 @@ public class TemporalSetBenchmarks public void Setup() { _set = new TemporalSet(); - _testData = Enumerable.Range(0, 10000).ToList(); + _testData = Enumerable.Range(0, 5000).ToList(); } - [Benchmark(Description = "Add 10,000 items")] + [Benchmark(Description = "Add 5,000 items")] public void AddItems() { foreach (var item in _testData) diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSlidingWindowSetBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSlidingWindowSetBenchmarks.cs index aeb978a..2510ed6 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSlidingWindowSetBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSlidingWindowSetBenchmarks.cs @@ -1,4 +1,4 @@ -// (c) 2025 Francesco Del Re +// (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using BenchmarkDotNet.Attributes; using TemporalCollections.Collections; @@ -15,10 +15,10 @@ public class TemporalSlidingWindowSetBenchmarks public void Setup() { _set = new TemporalSlidingWindowSet(TimeSpan.FromMinutes(10)); - _testData = Enumerable.Range(0, 10000).ToList(); + _testData = Enumerable.Range(0, 5000).ToList(); } - [Benchmark(Description = "Add 10,000 items")] + [Benchmark(Description = "Add 5,000 items")] public void AddItems() { foreach (var item in _testData) diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSortedListBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSortedListBenchmarks.cs index 8ea8d15..87ad5de 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSortedListBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSortedListBenchmarks.cs @@ -1,4 +1,4 @@ -// (c) 2025 Francesco Del Re +// (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using BenchmarkDotNet.Attributes; using TemporalCollections.Collections; @@ -15,10 +15,10 @@ public class TemporalSortedListBenchmarks public void Setup() { _list = new TemporalSortedList(); - _testData = Enumerable.Range(0, 10000).ToList(); + _testData = Enumerable.Range(0, 5000).ToList(); } - [Benchmark(Description = "Add 10,000 items")] + [Benchmark(Description = "Add 5,000 items")] public void AddItems() { foreach (var item in _testData) diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalStackBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalStackBenchmarks.cs index 08699cc..1202ace 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalStackBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalStackBenchmarks.cs @@ -1,4 +1,4 @@ -// (c) 2025 Francesco Del Re +// (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using BenchmarkDotNet.Attributes; using TemporalCollections.Collections; @@ -15,10 +15,10 @@ public class TemporalStackBenchmarks public void Setup() { _stack = new TemporalStack(); - _testData = Enumerable.Range(0, 10000).ToList(); + _testData = Enumerable.Range(0, 5000).ToList(); } - [Benchmark(Description = "Add 10,000 items")] + [Benchmark(Description = "Add 5,000 items")] public void AddItems() { foreach (var item in _testData) diff --git a/src/TemporalCollections.PerformanceTests/Program.cs b/src/TemporalCollections.PerformanceTests/Program.cs index 042f519..889e851 100644 --- a/src/TemporalCollections.PerformanceTests/Program.cs +++ b/src/TemporalCollections.PerformanceTests/Program.cs @@ -3,17 +3,32 @@ using BenchmarkDotNet.Columns; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Loggers; using BenchmarkDotNet.Order; using BenchmarkDotNet.Reports; using BenchmarkDotNet.Running; +using System.Globalization; using TemporalCollections.PerformanceTests.Benchmarks; class Program { static void Main(string[] args) { + bool fullRun = args.Contains("--full", StringComparer.OrdinalIgnoreCase); + + // Fast job: fewer warmup/measurement iterations, single launch. + // Use --full to run the BenchmarkDotNet default (more accurate but much slower) job. + var job = fullRun + ? Job.Default + : Job.Default + .WithLaunchCount(1) + .WithWarmupCount(3) + .WithIterationCount(10) + .WithId("Fast"); + var config = ManualConfig.Create(DefaultConfig.Instance) + .AddJob(job.AsDefault()) .AddLogger(ConsoleLogger.Default) .AddColumn(TargetMethodColumn.Method) .AddColumn(StatisticColumn.Mean) @@ -42,15 +57,8 @@ static void Main(string[] args) typeof(TemporalCacheBenchmarks), }; - var allSummaries = new List(); - - foreach (var type in benchmarkTypes) - { - Console.WriteLine($"Running benchmarks for {type.Name}..."); - var summary = BenchmarkRunner.Run(type, config); - allSummaries.Add(summary); - Console.WriteLine(); - } + // Run all types in a single invocation so the benchmark project is generated and built only once. + var allSummaries = BenchmarkRunner.Run(benchmarkTypes.ToArray(), config).ToList(); PrintCombinedSummaryTable(allSummaries); @@ -61,38 +69,39 @@ static void PrintCombinedSummaryTable(List summaries) { Console.WriteLine(); Console.WriteLine("Combined Benchmark Results Summary:"); - Console.WriteLine(new string('-', 100)); - Console.WriteLine($"| {"Structure",-25} | {"Method",-30} | {"Mean (ms)",10} | {"StdDev (ms)",12} | {"Min (ms)",10} | {"Max (ms)",10} | {"Ops/sec",10} |"); - Console.WriteLine(new string('-', 100)); + Console.WriteLine(); + Console.WriteLine("| Structure | Method | Params | Mean (µs) | StdDev (µs) | Min (µs) | Max (µs) | Ops/sec |"); + Console.WriteLine("|---|---|---|---:|---:|---:|---:|---:|"); + + var inv = CultureInfo.InvariantCulture; foreach (var summary in summaries) { - int reportsCount = 0; - if (summary != null) - { - reportsCount = summary.Reports.Count(); - } - - string structureName = reportsCount > 0 - ? summary.Reports.First().BenchmarkCase.Descriptor.Type.Name - : "Unknown"; + if (summary == null) continue; foreach (var report in summary.Reports) { var stats = report.ResultStatistics; if (stats == null) continue; - string method = report.BenchmarkCase.Descriptor.WorkloadMethod.Name; - string mean = (stats.Mean / 1_000_000).ToString("F4"); // ns to ms - string stddev = (stats.StandardDeviation / 1_000_000).ToString("F4"); - string min = (stats.Min / 1_000_000).ToString("F4"); - string max = (stats.Max / 1_000_000).ToString("F4"); - string opsPerSec = (1_000_000_000 / stats.Mean).ToString("F0"); + var descriptor = report.BenchmarkCase.Descriptor; + string structureName = descriptor.Type.Name.Replace("Benchmarks", string.Empty); + string method = descriptor.WorkloadMethod.Name; + string parameters = report.BenchmarkCase.HasParameters + ? string.Join(", ", report.BenchmarkCase.Parameters.Items.Select(p => $"{p.Name}={p.Value}")) + : "-"; + + // ns to µs + string mean = (stats.Mean / 1_000).ToString("N3", inv); + string stddev = (stats.StandardDeviation / 1_000).ToString("N3", inv); + string min = (stats.Min / 1_000).ToString("N3", inv); + string max = (stats.Max / 1_000).ToString("N3", inv); + string opsPerSec = (1_000_000_000 / stats.Mean).ToString("N0", inv); - Console.WriteLine($"| {structureName,-25} | {method,-30} | {mean,10} | {stddev,12} | {min,10} | {max,10} | {opsPerSec,10} |"); + Console.WriteLine($"| {structureName} | {method} | {parameters} | {mean} | {stddev} | {min} | {max} | {opsPerSec} |"); } } - Console.WriteLine(new string('-', 100)); + Console.WriteLine(); } } \ No newline at end of file diff --git a/src/TemporalCollections/Collections/TemporalMultimap.cs b/src/TemporalCollections/Collections/TemporalMultimap.cs index ae30db9..2e331d3 100644 --- a/src/TemporalCollections/Collections/TemporalMultimap.cs +++ b/src/TemporalCollections/Collections/TemporalMultimap.cs @@ -260,7 +260,8 @@ public override IEnumerable>> GetInRange if (f > t) (f, t) = (t, f); - var acc = new List>>(); + var runs = new List(); + int total = 0; foreach (var list in _byKey.Values) { if (list.Count == 0) continue; @@ -269,14 +270,14 @@ public override IEnumerable>> GetInRange int i0 = LowerBound(list, f); int i1 = UpperExclusive(list, t); - for (int i = i0; i < i1; i++) - acc.Add(list[i]); + if (i0 < i1) + { + runs.Add(new Run(list, i0, i1)); + total += i1 - i0; + } } - if (acc.Count <= 1) - return acc.ToArray(); - acc.Sort((a, b) => a.Timestamp.UtcTicks.CompareTo(b.Timestamp.UtcTicks)); - return acc.ToArray(); + return MergeRuns(runs, total); } } @@ -395,21 +396,22 @@ public override IEnumerable>> GetBefore( return []; long c = time.UtcTicks; - var acc = new List>>(); + var runs = new List(); + int total = 0; foreach (var list in _byKey.Values) { if (list.Count == 0) continue; if (list[0].Timestamp.UtcTicks >= c) continue; int end = LowerBound(list, c); // [0..end-1] are < c - for (int i = 0; i < end; i++) - acc.Add(list[i]); + if (end > 0) + { + runs.Add(new Run(list, 0, end)); + total += end; + } } - if (acc.Count <= 1) - return acc.ToArray(); - acc.Sort((a, b) => a.Timestamp.UtcTicks.CompareTo(b.Timestamp.UtcTicks)); - return acc.ToArray(); + return MergeRuns(runs, total); } } @@ -422,21 +424,22 @@ public override IEnumerable>> GetAfter(D return []; long c = time.UtcTicks; - var acc = new List>>(); + var runs = new List(); + int total = 0; foreach (var list in _byKey.Values) { if (list.Count == 0) continue; if (list[^1].Timestamp.UtcTicks <= c) continue; int start = UpperExclusive(list, c); // first > c - for (int i = start; i < list.Count; i++) - acc.Add(list[i]); + if (start < list.Count) + { + runs.Add(new Run(list, start, list.Count)); + total += list.Count - start; + } } - if (acc.Count <= 1) - return acc.ToArray(); - acc.Sort((a, b) => a.Timestamp.UtcTicks.CompareTo(b.Timestamp.UtcTicks)); - return acc.ToArray(); + return MergeRuns(runs, total); } } @@ -586,6 +589,58 @@ public override TimeSpan GetTimeSpan() // ---------- Private helpers ---------- + /// A contiguous, timestamp-sorted slice [Start, End) of a per-key list. + private readonly record struct Run(List>> List, int Start, int End); + + /// + /// K-way merge of per-key sorted slices into a single array ordered by timestamp. + /// Complexity: O(n log k) where k is the number of slices, instead of O(n log n) for a full sort. + /// + private static TemporalItem>[] MergeRuns(List runs, int total) + { + if (total == 0) + return []; + + var result = new TemporalItem>[total]; + + if (runs.Count == 1) + { + var single = runs[0]; + single.List.CopyTo(single.Start, result, 0, total); + return result; + } + + var pos = new int[runs.Count]; + var heap = new PriorityQueue(runs.Count); + for (int r = 0; r < runs.Count; r++) + { + pos[r] = runs[r].Start; + heap.Enqueue(r, runs[r].List[pos[r]].Timestamp.UtcTicks); + } + + int w = 0; + while (heap.TryDequeue(out int r, out _)) + { + var run = runs[r]; + var list = run.List; + int p = pos[r]; + + // Drain this run while it stays <= the next smallest head among the other runs. + bool hasOther = heap.TryPeek(out _, out long nextTicks); + do + { + result[w++] = list[p++]; + } + while (p < run.End && (!hasOther || list[p].Timestamp.UtcTicks <= nextTicks)); + + pos[r] = p; + if (p < run.End) + heap.Enqueue(r, list[p].Timestamp.UtcTicks); + } + + return result; + } + /// Per-list lower bound: first index with timestamp ticks >= . private static int LowerBound(List>> list, long ticks) { From d53128adc16876655c8a955df186af7222f646fc Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Fri, 23 Oct 2026 19:36:11 +0200 Subject: [PATCH 31/34] fix(collections): resolve ordering races, hash collisions and post-trim overflow --- global.json | 1 + .../TemporalCircularBufferTests.cs | 20 ++++ .../Collections/TemporalIntervalTreeTests.cs | 68 ++++++++++++ .../Collections/TemporalQueueTests.cs | 25 +++++ .../TemporalSegmentedArrayTests.cs | 31 ++++++ .../Collections/TemporalCircularBuffer.cs | 4 +- .../Collections/TemporalDictionary.cs | 12 ++- .../Collections/TemporalIntervalTree.cs | 102 +++++++++++++----- .../Collections/TemporalMultimap.cs | 4 +- .../Collections/TemporalQueue.cs | 3 +- .../Collections/TemporalSegmentedArray.cs | 16 ++- .../TemporalCollections.csproj | 2 +- 12 files changed, 248 insertions(+), 40 deletions(-) create mode 100644 global.json diff --git a/global.json b/global.json new file mode 100644 index 0000000..61a3822 --- /dev/null +++ b/global.json @@ -0,0 +1 @@ +{ "test": { "runner": "Microsoft.Testing.Platform" } } diff --git a/src/TemporalCollections.Tests/Collections/TemporalCircularBufferTests.cs b/src/TemporalCollections.Tests/Collections/TemporalCircularBufferTests.cs index 0d8a387..3102234 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalCircularBufferTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalCircularBufferTests.cs @@ -354,5 +354,25 @@ public void TemporalCircularBuffer_GetNearest_WorksAndTiesPreferLater() Assert.Equal("C", nearCeil!.Value); } } + + [Fact] + public async Task Add_Concurrent_KeepsChronologicalOrder() + { + const int threads = 8, perThread = 5_000; + var buffer = new TemporalCircularBuffer(threads * perThread); + using var gate = new Barrier(threads); + + var tasks = Enumerable.Range(0, threads).Select(t => Task.Factory.StartNew(() => + { + gate.SignalAndWait(); + for (int i = 0; i < perThread; i++) buffer.Add(t * perThread + i); + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)); + await Task.WhenAll(tasks); + + var items = buffer.GetSnapshot(); + Assert.Equal(threads * perThread, items.Count); + for (int i = 1; i < items.Count; i++) + Assert.True(items[i - 1].Timestamp < items[i].Timestamp, $"Out of order at index {i}"); + } } } \ No newline at end of file diff --git a/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs b/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs index de3c17d..d0e4e3a 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs @@ -660,5 +660,73 @@ public void TemporalIntervalTree_GetNearest_WorksAndTiesPreferLater() var tie = tree.GetNearest(mid); Assert.Equal("C", tie!.Value); } + + /// Distinct values that always produce the same hash code. + private sealed record Colliding(string Name) + { + public override int GetHashCode() => 42; + } + + [Fact] + public void Insert_DistinctValuesWithSameHash_AreAllStored() + { + var tree = new TemporalIntervalTree(); + var s = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); + var e = s.AddMinutes(10); + + tree.Insert(s, e, new Colliding("a")); + tree.Insert(s, e, new Colliding("b")); + tree.Insert(s, e, new Colliding("c")); + + var values = tree.Query(s, e).Select(v => v.Name).OrderBy(x => x).ToList(); + Assert.Equal(new[] { "a", "b", "c" }, values); + Assert.Equal(3, tree.CountInRange(s, e)); + } + + [Fact] + public void Remove_WithCollidingHash_RemovesOnlyMatchingValue() + { + var tree = new TemporalIntervalTree(); + var s = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); + var e = s.AddMinutes(10); + + for (int i = 0; i < 50; i++) + tree.Insert(s, e, new Colliding("v" + i)); + + Assert.True(tree.Remove(s, e, new Colliding("v25"))); + Assert.False(tree.Remove(s, e, new Colliding("v25"))); + Assert.False(tree.Remove(s, e, new Colliding("missing"))); + + var values = tree.Query(s, e).Select(v => v.Name).ToList(); + Assert.Equal(49, values.Count); + Assert.DoesNotContain("v25", values); + } + + [Fact] + public void Insert_ExactDuplicate_IsIgnored() + { + var tree = new TemporalIntervalTree(); + var s = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); + var e = s.AddMinutes(1); + + tree.Insert(s, e, new Colliding("x")); + tree.Insert(s, e, new Colliding("x")); + + Assert.Single(tree.Query(s, e)); + } + + [Fact] + public void InsertAndRemove_NullValues_AreSupported() + { + var tree = new TemporalIntervalTree(); + var s = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); + var e = s.AddMinutes(1); + + tree.Insert(s, e, null); + tree.Insert(s, e, null); + Assert.Single(tree.Query(s, e)); + Assert.True(tree.Remove(s, e, null)); + Assert.Empty(tree.Query(s, e)); + } } } \ No newline at end of file diff --git a/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs b/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs index 9d08535..25f0e3f 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalQueueTests.cs @@ -915,5 +915,30 @@ public void TryPeek_ReturnsSameItemAsDequeue() Assert.Equal(peeked!.Value, dequeued.Value); Assert.Equal(peeked.Timestamp, dequeued.Timestamp); } + + [Fact] + public async Task Enqueue_Concurrent_KeepsChronologicalOrder() + { + const int threads = 8, perThread = 5_000; + var queue = new TemporalQueue(); + using var gate = new Barrier(threads); + + var tasks = Enumerable.Range(0, threads).Select(t => Task.Factory.StartNew(() => + { + gate.SignalAndWait(); + for (int i = 0; i < perThread; i++) queue.Enqueue(t * perThread + i); + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)); + await Task.WhenAll(tasks); + + var previous = DateTimeOffset.MinValue; + int count = 0; + while (queue.TryDequeue(out var item)) + { + Assert.True(previous < item!.Timestamp, $"Out of order at index {count}"); + previous = item.Timestamp; + count++; + } + Assert.Equal(threads * perThread, count); + } } } \ No newline at end of file diff --git a/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs b/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs index d54691c..9a192b8 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs @@ -271,5 +271,36 @@ public void Concurrency_AddValue_Works() var arr = col.ToArray(); AssertStrictlyIncreasing(arr); // TemporalItem.Create ensures monotonic UTC ticks per T } + + [Fact] + public void Add_AfterTrimExcess_DoesNotThrow_AndKeepsOrder() + { + var col = new TemporalSegmentedArray(segmentCapacity: 8); + for (int i = 0; i < 3; i++) col.AddValue(i); + + col.TrimExcess(); // shrinks the last segment to Count (3) + + for (int i = 3; i < 20; i++) col.AddValue(i); + + var arr = col.ToArray(); + Assert.Equal(20, arr.Length); + Assert.Equal(Enumerable.Range(0, 20), arr.Select(x => x.Value)); + AssertStrictlyIncreasing(arr); + } + + [Fact] + public void AddOutOfOrder_AfterTrimExcess_DoesNotThrow() + { + var col = new TemporalSegmentedArray(segmentCapacity: 8); + var a = col.AddValue(1); + col.AddValue(2); + col.AddValue(3); + col.TrimExcess(); + + // Positional insert into a trimmed segment + col.Add(new TemporalItem(0, a.Timestamp.AddTicks(-1))); + + Assert.Equal(new[] { 0, 1, 2, 3 }, col.ToArray().Select(x => x.Value)); + } } } \ No newline at end of file diff --git a/src/TemporalCollections/Collections/TemporalCircularBuffer.cs b/src/TemporalCollections/Collections/TemporalCircularBuffer.cs index d02ece2..47e0f8c 100644 --- a/src/TemporalCollections/Collections/TemporalCircularBuffer.cs +++ b/src/TemporalCollections/Collections/TemporalCircularBuffer.cs @@ -60,10 +60,10 @@ public int Count /// public void Add(T item) { - var temporalItem = TemporalItem.Create(item, _timeProvider); - lock (_lock) { + // Timestamp is created under the lock so that insertion order matches timestamp order. + var temporalItem = TemporalItem.Create(item, _timeProvider); _buffer[_head] = temporalItem; _head = (_head + 1) % Capacity; diff --git a/src/TemporalCollections/Collections/TemporalDictionary.cs b/src/TemporalCollections/Collections/TemporalDictionary.cs index 0c96ecf..0ac05f3 100644 --- a/src/TemporalCollections/Collections/TemporalDictionary.cs +++ b/src/TemporalCollections/Collections/TemporalDictionary.cs @@ -41,9 +41,7 @@ public TemporalDictionary(TimeProvider timeProvider) /// public void Add(TKey key, TValue value) { - var temporalItem = TemporalItem.Create(value, _timeProvider); - - // Retry loop: if a concurrent RemoveOlderThan/RemoveRange removes the key + // Retry loop // between GetOrAdd and lock(list), the list becomes orphaned. We detect this // by verifying the list is still the one in the dictionary after acquiring the lock. while (true) @@ -64,6 +62,9 @@ public void Add(TKey key, TValue value) // // Therefore, we can safely append to the end of the list while preserving // the sorted-by-timestamp invariant. No BinarySearch + Insert is needed. + // The item is created under the per-key lock so concurrent Adds on the + // same key cannot append out of timestamp order. + var temporalItem = TemporalItem.Create(value, _timeProvider); list.Add(temporalItem); return; } @@ -122,7 +123,8 @@ public override void RemoveOlderThan(DateTimeOffset cutoff) list.RemoveRange(0, idx); if (list.Count == 0) - _dict.TryRemove(kvp.Key, out _); + // Remove only if the key still maps to this list (a concurrent Add may have replaced it). + _dict.TryRemove(new KeyValuePair>>(kvp.Key, list)); } } } @@ -267,7 +269,7 @@ public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) list.RemoveRange(lo, removeCount); if (list.Count == 0) - _dict.TryRemove(kvp.Key, out _); + _dict.TryRemove(new KeyValuePair>>(kvp.Key, list)); } } } diff --git a/src/TemporalCollections/Collections/TemporalIntervalTree.cs b/src/TemporalCollections/Collections/TemporalIntervalTree.cs index 306f45c..3962908 100644 --- a/src/TemporalCollections/Collections/TemporalIntervalTree.cs +++ b/src/TemporalCollections/Collections/TemporalIntervalTree.cs @@ -35,7 +35,17 @@ private sealed class Node public T Value = default!; /// - /// The maximum End of this node or any of its descendants (UTC). + /// Cached hash of (ordering key component). + /// + public readonly int Hash; + + /// + /// Unique insertion sequence; final tie-break for distinct values sharing the same hash. + /// + public readonly long Seq; + + /// + /// The maximum End /// public DateTimeOffset MaxEnd; @@ -47,7 +57,7 @@ private sealed class Node public Node? Left; public Node? Right; - public Node(DateTimeOffset start, DateTimeOffset end, T value, int priority) + public Node(DateTimeOffset start, DateTimeOffset end, T value, int hash, long seq, int priority) { if (end < start) throw new ArgumentException("End must be >= Start", nameof(end)); @@ -55,6 +65,8 @@ public Node(DateTimeOffset start, DateTimeOffset end, T value, int priority) Start = start; End = end; Value = value; + Hash = hash; + Seq = seq; Priority = priority; MaxEnd = end; } @@ -63,6 +75,7 @@ public Node(DateTimeOffset start, DateTimeOffset end, T value, int priority) private Node? _root; private readonly Lock _lock = new(); private readonly Random _rng = new(); // used only under _lock + private long _seq; // used only under _lock /// /// Inserts a new interval with an associated value into the tree. @@ -76,7 +89,13 @@ public void Insert(DateTimeOffset start, DateTimeOffset end, T value) lock (_lock) { - _root = InsertTreap(_root, s, e, value); + int h = HashOf(value); + + // Exact duplicate (Start, End, Value): no-op. + if (FindNode(_root, s, e, h, value) is not null) + return; + + _root = InsertTreap(_root, s, e, value, h, ++_seq); } } @@ -90,8 +109,12 @@ public bool Remove(DateTimeOffset start, DateTimeOffset end, T value) lock (_lock) { + var target = FindNode(_root, s, e, HashOf(value), value); + if (target is null) + return false; + bool removed; - (_root, removed) = RemoveTreap(_root, s, e, value); + (_root, removed) = RemoveTreap(_root, target.Start, target.End, target.Hash, target.Seq); return removed; } } @@ -308,22 +331,49 @@ public override int CountSince(DateTimeOffset since) #region Internal helpers (UTC DateTimeOffset, Treap) + private static int HashOf(T value) + => value is null ? 0 : EqualityComparer.Default.GetHashCode(value); + /// - /// Total order over (Start, End, Value) to avoid long equal-key chains. + /// Compares (Start, End, Hash) only; nodes sharing this prefix form a contiguous in-order group. /// - private static int CompareKey(DateTimeOffset s1, DateTimeOffset e1, T v1, - DateTimeOffset s2, DateTimeOffset e2, T v2) + private static int ComparePrefix(DateTimeOffset s1, DateTimeOffset e1, int h1, Node n) { - int c = s1.CompareTo(s2); + int c = s1.CompareTo(n.Start); if (c != 0) return c; - c = e1.CompareTo(e2); + c = e1.CompareTo(n.End); if (c != 0) return c; + return h1.CompareTo(n.Hash); + } + + /// + /// Total order over (Start, End, Hash, Seq). Seq is unique, so distinct values + /// with colliding hashes never compare equal. + /// + private static int CompareKey(DateTimeOffset s1, DateTimeOffset e1, int h1, long q1, Node n) + { + int c = ComparePrefix(s1, e1, h1, n); + return c != 0 ? c : q1.CompareTo(n.Seq); + } + + /// + /// Finds a node with exactly (start, end, value), scanning the group of nodes sharing the same hash. + /// + private static Node? FindNode(Node? node, DateTimeOffset start, DateTimeOffset end, int hash, T value) + { + while (node is not null) + { + int c = ComparePrefix(start, end, hash, node); + if (c < 0) { node = node.Left; continue; } + if (c > 0) { node = node.Right; continue; } - if (EqualityComparer.Default.Equals(v1, v2)) return 0; + if (EqualityComparer.Default.Equals(value, node.Value)) + return node; - int h1 = HashCode.Combine(v1); - int h2 = HashCode.Combine(v2); - return h1 < h2 ? -1 : (h1 > h2 ? 1 : 0); + return FindNode(node.Left, start, end, hash, value) + ?? FindNode(node.Right, start, end, hash, value); + } + return null; } /// @@ -366,30 +416,26 @@ private static Node RotateLeft(Node x) /// /// Treap insert with priority heap property (min-heap). /// - private Node InsertTreap(Node? node, DateTimeOffset start, DateTimeOffset end, T value) + private Node InsertTreap(Node? node, DateTimeOffset start, DateTimeOffset end, T value, int hash, long seq) { if (node is null) - return new Node(start, end, value, _rng.Next()); + return new Node(start, end, value, hash, seq, _rng.Next()); - int cmp = CompareKey(start, end, value, node.Start, node.End, node.Value); + // Seq is unique, so cmp is never 0. + int cmp = CompareKey(start, end, hash, seq, node); if (cmp < 0) { - node.Left = InsertTreap(node.Left, start, end, value); + node.Left = InsertTreap(node.Left, start, end, value, hash, seq); if (node.Left!.Priority < node.Priority) node = RotateRight(node); } - else if (cmp > 0) + else { - node.Right = InsertTreap(node.Right, start, end, value); + node.Right = InsertTreap(node.Right, start, end, value, hash, seq); if (node.Right!.Priority < node.Priority) node = RotateLeft(node); } - else - { - // Exact duplicate (Start, End, Value): no-op. - return node; - } Update(node); return node; @@ -399,20 +445,20 @@ private Node InsertTreap(Node? node, DateTimeOffset start, DateTimeOffset end, T /// Removes a node matching (start, end, value) using treap deletion: /// rotate the node down according to child priorities until it becomes a leaf, then drop it. /// - private static (Node? node, bool removed) RemoveTreap(Node? node, DateTimeOffset start, DateTimeOffset end, T value) + private static (Node? node, bool removed) RemoveTreap(Node? node, DateTimeOffset start, DateTimeOffset end, int hash, long seq) { if (node is null) return (null, false); - int cmp = CompareKey(start, end, value, node.Start, node.End, node.Value); + int cmp = CompareKey(start, end, hash, seq, node); bool removed; if (cmp < 0) { - (node.Left, removed) = RemoveTreap(node.Left, start, end, value); + (node.Left, removed) = RemoveTreap(node.Left, start, end, hash, seq); } else if (cmp > 0) { - (node.Right, removed) = RemoveTreap(node.Right, start, end, value); + (node.Right, removed) = RemoveTreap(node.Right, start, end, hash, seq); } else { diff --git a/src/TemporalCollections/Collections/TemporalMultimap.cs b/src/TemporalCollections/Collections/TemporalMultimap.cs index 2e331d3..64f0a4b 100644 --- a/src/TemporalCollections/Collections/TemporalMultimap.cs +++ b/src/TemporalCollections/Collections/TemporalMultimap.cs @@ -68,9 +68,11 @@ public int KeyCount /// public TemporalItem> AddValue(TKey key, TValue value) { - var item = TemporalItem>.Create(new KeyValuePair(key, value), _timeProvider); + TemporalItem> item; lock (_lock) { + // Created under the lock so per-key append order matches timestamp order. + item = TemporalItem>.Create(new KeyValuePair(key, value), _timeProvider); if (!_byKey.TryGetValue(key, out var list)) { list = new List>>(4); diff --git a/src/TemporalCollections/Collections/TemporalQueue.cs b/src/TemporalCollections/Collections/TemporalQueue.cs index 3588488..f276767 100644 --- a/src/TemporalCollections/Collections/TemporalQueue.cs +++ b/src/TemporalCollections/Collections/TemporalQueue.cs @@ -50,9 +50,10 @@ public int Count /// public void Enqueue(T item) { - var temporalItem = TemporalItem.Create(item, _timeProvider); lock (_lock) { + // Timestamp is created under the lock so that FIFO order matches timestamp order. + var temporalItem = TemporalItem.Create(item, _timeProvider); _queue.Enqueue(temporalItem); } } diff --git a/src/TemporalCollections/Collections/TemporalSegmentedArray.cs b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs index ae1b042..6c822c6 100644 --- a/src/TemporalCollections/Collections/TemporalSegmentedArray.cs +++ b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs @@ -231,7 +231,8 @@ public void Add(TemporalItem item) var last = _segments[^1]; long t = item.Timestamp.UtcTicks; - if (last.Count < _segmentCapacity && (last.Count == 0 || last.MaxTicks <= t)) + EnsureCapacity(last); + if (!last.IsFull && (last.Count == 0 || last.MaxTicks <= t)) { last.Append(item); _count++; @@ -288,7 +289,8 @@ public void AddSorted(IEnumerable> sortedItems) } var last = _segments[^1]; long t = it.Timestamp.UtcTicks; - if (last.Count < _segmentCapacity && (last.Count == 0 || last.MaxTicks <= t)) + EnsureCapacity(last); + if (!last.IsFull && (last.Count == 0 || last.MaxTicks <= t)) { last.Append(it); _count++; @@ -699,6 +701,7 @@ private int FirstSegWithMaxGt(long ticks) private void InsertAt(int segIdx, int elemIdx, in TemporalItem item) { var seg = _segments[segIdx]; + EnsureCapacity(seg); if (!seg.IsFull) { seg.InsertAt(elemIdx, item); @@ -729,6 +732,15 @@ private void InsertAt(int segIdx, int elemIdx, in TemporalItem item) else right.InsertAt(elemIdx - seg.Count, item); } + + /// + /// Restores a segment's backing array to the configured capacity if it was shrunk by . + /// + private void EnsureCapacity(Segment seg) + { + if (seg.Items.Length < _segmentCapacity) + Array.Resize(ref seg.Items, _segmentCapacity); + } #endregion } } \ No newline at end of file diff --git a/src/TemporalCollections/TemporalCollections.csproj b/src/TemporalCollections/TemporalCollections.csproj index 7492431..d070edd 100644 --- a/src/TemporalCollections/TemporalCollections.csproj +++ b/src/TemporalCollections/TemporalCollections.csproj @@ -16,7 +16,7 @@ git LICENSE temporal;collections;time-series;thread-safe;dictionary;queue;stack;dotnet - Performance improvements: binary-search-based range queries in TemporalDictionary; TryDequeue/TryPeek added to TemporalQueue. + Performance improvements: binary-search-based range queries in TemporalDictionary; TryDequeue/TryPeek added to TemporalQueue. Bug fixes: TemporalIntervalTree no longer drops or mis-removes distinct values with colliding hash codes; TemporalCircularBuffer.Add, TemporalQueue.Enqueue, TemporalDictionary.Add and TemporalMultimap.AddValue now guarantee chronological order under concurrent writers; TemporalSegmentedArray no longer throws when adding after TrimExcess; TemporalDictionary pruning no longer removes a key re-created concurrently. From bae2373ec69946465d427756ad75d4b4b2e27d8d Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 24 Oct 2026 19:03:28 +0200 Subject: [PATCH 32/34] fix(collections): handle null priorities and narrow exception handling --- .../Collections/TemporalPriorityQueueTests.cs | 14 ++++++++++++++ .../Collections/TemporalPriorityQueue.cs | 2 +- src/TemporalCollections/TemporalCollections.csproj | 2 +- .../Utilities/TimeNormalization.cs | 4 ++-- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs b/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs index 4627aff..1b7cdca 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalPriorityQueueTests.cs @@ -600,5 +600,19 @@ public void Concurrency_Enqueue_ProducesMonotonicTimestamps_InQueries() var seen = new HashSet(all.Select(x => x.Value)); Assert.Equal(2000, seen.Count); } + + [Fact] + public void Enqueue_NullPriority_DoesNotThrow_AndSortsFirst() + { + var pq = new TemporalPriorityQueue(); + pq.Enqueue(1, "b"); + pq.Enqueue(2, null!); + pq.Enqueue(3, "a"); + + Assert.True(pq.TryDequeue(out var first)); + Assert.Equal(2, first); + Assert.True(pq.TryDequeue(out var second)); + Assert.Equal(3, second); + } } } \ No newline at end of file diff --git a/src/TemporalCollections/Collections/TemporalPriorityQueue.cs b/src/TemporalCollections/Collections/TemporalPriorityQueue.cs index 4a75aba..dc18c77 100644 --- a/src/TemporalCollections/Collections/TemporalPriorityQueue.cs +++ b/src/TemporalCollections/Collections/TemporalPriorityQueue.cs @@ -391,7 +391,7 @@ public int CompareTo(QueueItem? other) { if (other is null) return 1; - int c = Priority.CompareTo(other.Priority); + int c = Comparer.Default.Compare(Priority, other.Priority); if (c != 0) return c; c = Timestamp.CompareTo(other.Timestamp); diff --git a/src/TemporalCollections/TemporalCollections.csproj b/src/TemporalCollections/TemporalCollections.csproj index d070edd..e56b25f 100644 --- a/src/TemporalCollections/TemporalCollections.csproj +++ b/src/TemporalCollections/TemporalCollections.csproj @@ -16,7 +16,7 @@ git LICENSE temporal;collections;time-series;thread-safe;dictionary;queue;stack;dotnet - Performance improvements: binary-search-based range queries in TemporalDictionary; TryDequeue/TryPeek added to TemporalQueue. Bug fixes: TemporalIntervalTree no longer drops or mis-removes distinct values with colliding hash codes; TemporalCircularBuffer.Add, TemporalQueue.Enqueue, TemporalDictionary.Add and TemporalMultimap.AddValue now guarantee chronological order under concurrent writers; TemporalSegmentedArray no longer throws when adding after TrimExcess; TemporalDictionary pruning no longer removes a key re-created concurrently. + Performance improvements: binary-search-based range queries in TemporalDictionary; TryDequeue/TryPeek added to TemporalQueue. Bug fixes: TemporalIntervalTree no longer drops or mis-removes distinct values with colliding hash codes; TemporalCircularBuffer.Add, TemporalQueue.Enqueue, TemporalDictionary.Add and TemporalMultimap.AddValue now guarantee chronological order under concurrent writers; TemporalSegmentedArray no longer throws when adding after TrimExcess; TemporalDictionary pruning no longer removes a key re-created concurrently; TemporalPriorityQueue no longer throws on null priorities. diff --git a/src/TemporalCollections/Utilities/TimeNormalization.cs b/src/TemporalCollections/Utilities/TimeNormalization.cs index a62216c..935dfb4 100644 --- a/src/TemporalCollections/Utilities/TimeNormalization.cs +++ b/src/TemporalCollections/Utilities/TimeNormalization.cs @@ -84,7 +84,7 @@ public static bool TryToUtcOffset( UnspecifiedPolicy unspecifiedPolicy = UnspecifiedPolicy.Reject) { try { utc = ToUtcOffset(dt, nameof(dt), unspecifiedPolicy); return true; } - catch { utc = default; return false; } + catch (ArgumentException) { utc = default; return false; } } /// @@ -100,7 +100,7 @@ public static long UtcTicks(DateTime dt, UnspecifiedPolicy unspecifiedPolicy = U => dt.HasValue ? UtcTicks(dt.Value, unspecifiedPolicy) : (long?)null; /// - /// Normalizes a [from, to] range to UTC and validates that from <= to. + /// Normalizes a [from, to] range to UTC; if from > to, the bounds are swapped. /// public static (DateTimeOffset fromUtc, DateTimeOffset toUtc) NormalizeRange( DateTime from, DateTime to, From fd160f9e6ab4de52f0f1eb1712dfcb3026ae8cf9 Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 24 Oct 2026 19:10:42 +0200 Subject: [PATCH 33/34] feat(interval-tree): add explicit overlap/end-based APIs and document semantics --- README.md | 4 + assets/logo.svg | 20 +++++ .../Collections/TemporalIntervalTreeTests.cs | 76 ++++++++++++++++- .../Collections/TemporalIntervalTree.cs | 82 +++++++++++++++++-- .../TemporalCollections.csproj | 2 +- 5 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 assets/logo.svg diff --git a/README.md b/README.md index 202e355..6ce9d70 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ TemporalCollections logo +

+ # TemporalCollections [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..5a1e129 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,20 @@ + + TemporalCollections + + + + + + + + + + + + + + + + TemporalCollections + TIME-AWARE THREAD-SAFE COLLECTIONS FOR .NET + diff --git a/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs b/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs index d0e4e3a..5143aaa 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs @@ -728,5 +728,79 @@ public void InsertAndRemove_NullValues_AreSupported() Assert.True(tree.Remove(s, e, null)); Assert.Empty(tree.Query(s, e)); } + + private static TemporalIntervalTree BuildSemanticsTree(out DateTimeOffset t0) + { + // A: [t0, t0+10m] B: [t0+5m, t0+6m] C: [t0+20m, t0+30m] + t0 = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); + var tree = new TemporalIntervalTree(); + tree.Insert(t0, t0.AddMinutes(10), "A"); + tree.Insert(t0.AddMinutes(5), t0.AddMinutes(6), "B"); + tree.Insert(t0.AddMinutes(20), t0.AddMinutes(30), "C"); + return tree; + } + + [Fact] + public void GetOverlapping_And_CountOverlapping_AreConsistent() + { + var tree = BuildSemanticsTree(out var t0); + var from = t0.AddMinutes(8); + var to = t0.AddMinutes(25); + + var overlapping = tree.GetOverlapping(from, to).Select(i => i.Value).OrderBy(v => v).ToList(); + Assert.Equal(new[] { "A", "C" }, overlapping); + Assert.Equal(overlapping.Count, tree.CountOverlapping(from, to)); + Assert.Equal(overlapping.Count, tree.CountOverlapping(to, from)); + } + + [Fact] + public void GetInRange_UsesOverlap_WhileCountInRange_UsesStart() + { + var tree = BuildSemanticsTree(out var t0); + var from = t0.AddMinutes(8); + var to = t0.AddMinutes(25); + + // Pins current 1.x semantics: overlap vs Start-based. + Assert.Equal(2, tree.GetInRange(from, to).Count()); // A (overlaps) + C + Assert.Equal(1, tree.CountInRange(from, to)); // only C starts in range + } + + [Fact] + public void RemoveEndedBefore_KeepsActiveIntervals_AndMatchesRemoveOlderThan() + { + var t1 = BuildSemanticsTree(out var t0); + var t2 = BuildSemanticsTree(out _); + var cutoff = t0.AddMinutes(8); // B ended (6m), A still active (10m), C future + + t1.RemoveEndedBefore(cutoff); + t2.RemoveOlderThan(cutoff); + + var r1 = t1.GetOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).Select(i => i.Value).OrderBy(v => v).ToList(); + var r2 = t2.GetOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).Select(i => i.Value).OrderBy(v => v).ToList(); + Assert.Equal(new[] { "A", "C" }, r1); + Assert.Equal(r1, r2); + } + + [Fact] + public void RemoveEndedBefore_EndEqualToCutoff_IsKept() + { + var t0 = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); + var tree = new TemporalIntervalTree(); + tree.Insert(t0, t0.AddMinutes(1), "X"); + + tree.RemoveEndedBefore(t0.AddMinutes(1)); + Assert.Equal(1, tree.CountOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); + + tree.RemoveEndedBefore(t0.AddMinutes(1).AddTicks(1)); + Assert.Equal(0, tree.CountOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); + } + + [Fact] + public void CountOverlapping_Empty_ReturnsZero() + { + var tree = new TemporalIntervalTree(); + Assert.Equal(0, tree.CountOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); + Assert.Empty(tree.GetOverlapping(DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); + } } -} \ No newline at end of file +} diff --git a/src/TemporalCollections/Collections/TemporalIntervalTree.cs b/src/TemporalCollections/Collections/TemporalIntervalTree.cs index 3962908..7d0171e 100644 --- a/src/TemporalCollections/Collections/TemporalIntervalTree.cs +++ b/src/TemporalCollections/Collections/TemporalIntervalTree.cs @@ -11,6 +11,17 @@ namespace TemporalCollections.Collections /// Public API uses DateTime; internal timeline is DateTimeOffset (UTC). /// Treap-balanced (randomized) BST over (Start, End, Value) with augmented MaxEnd for interval queries. ///
+ /// + /// Time semantics of the members: + /// + /// returns intervals that overlap the range. + /// removes intervals whose End is before the cutoff. + /// All other members (CountInRange, RemoveRange, GetBefore, GetAfter, GetNearest, + /// GetEarliest, GetLatest, GetTimeSpan, CountSince) use the interval Start as timestamp. + /// + /// Prefer the explicit , and + /// for interval-specific queries. + /// /// The type of the value associated with each interval. public class TemporalIntervalTree : TimeQueryableBase { @@ -45,7 +56,7 @@ private sealed class Node public readonly long Seq; /// - /// The maximum End + /// The maximum End of this node or any of its descendants (UTC). /// public DateTimeOffset MaxEnd; @@ -123,7 +134,18 @@ public bool Remove(DateTimeOffset start, DateTimeOffset end, T value) /// Returns all values whose intervals overlap with the given query range. /// The returned items are wrapped as where Timestamp equals interval Start. ///
+ /// + /// Uses overlap semantics (Start <= queryEnd and End >= queryStart), unlike + /// which counts by Start. Equivalent to . + /// public override IEnumerable> GetInRange(DateTimeOffset queryStart, DateTimeOffset queryEnd) + => GetOverlapping(queryStart, queryEnd); + + /// + /// Returns all intervals overlapping [queryStart, queryEnd] (inclusive), i.e. + /// Start <= queryEnd and End >= queryStart. Timestamp of each item equals interval Start. + /// + public IReadOnlyList> GetOverlapping(DateTimeOffset queryStart, DateTimeOffset queryEnd) { var qs = queryStart; var qe = queryEnd; @@ -139,18 +161,44 @@ public override IEnumerable> GetInRange(DateTimeOffset queryStar } /// - /// Removes all intervals that have already ended strictly before the cutoff (End < cutoff). + /// Returns the number of intervals overlapping [queryStart, queryEnd] (inclusive). + /// Consistent with . /// - public override void RemoveOlderThan(DateTimeOffset cutoff) + public int CountOverlapping(DateTimeOffset queryStart, DateTimeOffset queryEnd) { - var c = cutoff; + var qs = queryStart; + var qe = queryEnd; + if (qs > qe) + (qs, qe) = (qe, qs); lock (_lock) { - _root = RemoveOlderThanInternal(_root, c); + return CountOverlap(_root, qs, qe); } } + /// + /// Removes all intervals that ended strictly before (End < cutoff). + /// Intervals still active at the cutoff are kept. + /// + public void RemoveEndedBefore(DateTimeOffset cutoff) + { + lock (_lock) + { + _root = RemoveOlderThanInternal(_root, cutoff); + } + } + + /// + /// Removes all intervals that have already ended strictly before the cutoff (End < cutoff). + /// + /// + /// Uses End semantics, unlike which removes by Start. + /// Equivalent to . + /// + public override void RemoveOlderThan(DateTimeOffset cutoff) + => RemoveEndedBefore(cutoff); + /// /// Returns all values whose intervals overlap with the given query range (values only). /// @@ -191,6 +239,10 @@ public override TimeSpan GetTimeSpan() /// /// Returns the number of items whose timestamps fall within [from, to] (inclusive). Timestamp == interval Start. /// + /// + /// Counts by Start, so the result may differ from GetInRange(from, to).Count() (overlap semantics). + /// Use for an overlap-consistent count. + /// public override int CountInRange(DateTimeOffset from, DateTimeOffset to) { var f = from; @@ -583,6 +635,26 @@ private static void QueryCollect(Node? node, DateTimeOffset qs, DateTimeOffset q QueryCollect(node.Right, qs, qe, result); } + /// + /// Counts intervals overlapping [qs, qe] using the same pruning as . + /// + private static int CountOverlap(Node? node, DateTimeOffset qs, DateTimeOffset qe) + { + if (node is null) return 0; + + int count = 0; + if (node.Left is not null && node.Left.MaxEnd >= qs) + count += CountOverlap(node.Left, qs, qe); + + if (node.Start <= qe && node.End >= qs) + count++; + + if (node.Right is not null && node.Start <= qe) + count += CountOverlap(node.Right, qs, qe); + + return count; + } + /// /// Collect values for compatibility with "Query" method. /// diff --git a/src/TemporalCollections/TemporalCollections.csproj b/src/TemporalCollections/TemporalCollections.csproj index e56b25f..1077ccd 100644 --- a/src/TemporalCollections/TemporalCollections.csproj +++ b/src/TemporalCollections/TemporalCollections.csproj @@ -16,7 +16,7 @@ git LICENSE temporal;collections;time-series;thread-safe;dictionary;queue;stack;dotnet - Performance improvements: binary-search-based range queries in TemporalDictionary; TryDequeue/TryPeek added to TemporalQueue. Bug fixes: TemporalIntervalTree no longer drops or mis-removes distinct values with colliding hash codes; TemporalCircularBuffer.Add, TemporalQueue.Enqueue, TemporalDictionary.Add and TemporalMultimap.AddValue now guarantee chronological order under concurrent writers; TemporalSegmentedArray no longer throws when adding after TrimExcess; TemporalDictionary pruning no longer removes a key re-created concurrently; TemporalPriorityQueue no longer throws on null priorities. + New: TemporalIntervalTree.GetOverlapping, CountOverlapping and RemoveEndedBefore make overlap/End-based semantics explicit. Performance improvements: binary-search-based range queries in TemporalDictionary; TryDequeue/TryPeek added to TemporalQueue. Bug fixes: TemporalIntervalTree no longer drops or mis-removes distinct values with colliding hash codes; TemporalCircularBuffer.Add, TemporalQueue.Enqueue, TemporalDictionary.Add and TemporalMultimap.AddValue now guarantee chronological order under concurrent writers; TemporalSegmentedArray no longer throws when adding after TrimExcess; TemporalDictionary pruning no longer removes a key re-created concurrently; TemporalPriorityQueue no longer throws on null priorities. From 71f7218c98860abce729c2f1aaa896a1011ae0be Mon Sep 17 00:00:00 2001 From: Francesco Del Re Date: Sat, 24 Oct 2026 19:13:02 +0200 Subject: [PATCH 34/34] build(nuget): add package icon --- assets/icon.png | Bin 0 -> 9653 bytes .../TemporalCollections.csproj | 5 +++++ 2 files changed, 5 insertions(+) create mode 100644 assets/icon.png diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e209e101d06e7d29a4f6efb8f4f746107db6499e GIT binary patch literal 9653 zcmXY1cRX9~_fL#iH7ixrsMemPrDkZYqGHxwReNtGh*g^^s;a~uRjc+)Y)YjcdyCqm z5)`q&>Ff9V(r(15W-haRr)=q@M-k$*MB+o= z$bZX4V&HJcFANgCtp|@MX zbi-_VSEDsbuebXXBNpAEaWsgptGbnv2-*EH_E>P(@~L1?RR~Ll8{Lg8|1@V0R?^}v z=xu1z29-)9?EdfvQQ=xfa;V8s-$fxMn^d1cckfM@tVoRTapCR;cl|O`+l1l0CWEuL zt`SG$Gl2)c7uK{#Ge*jze-VFb=Nvzt(dQ&x>iV*FRz7xUTS=>G#V5MN_z>8g7s{vL53OGE<+0-$+I^Nkc``f&j-Bt6C+bU=QtB}kXv$g()7c1rfSM(&S(zHO zYPbnzc*HPKN}SzbtG8k;cFtcf=$(F^37odt;;R+KIE_>n4^q>3#6^?o$rC`fh z^|xORrNM!A!tcoyT)71bhGpqHzLGV@Ya1u5)4Fp1keSz57nDnQ#8#`hUOoC!$Lu$Q zIn!n_kyo4pT{cG1wY`YHtAZb&No5t)@LoTad{={_@w>mWkwsLcL3o3K^gTnarJr@o zlk`nnw(|?cKdM@W=N^d`lhqz*_lg`SA|fC`xVIUDv6hRZW@pSZ8WzF!Ou3!84(q4% z7wxMrVi%P*SCI^+qmfJT-`7{@>5(KODYWKJ_kx`R zU6b;c2naidX_vuTIH0Goglfav&gji~IkQ}kO-gp|72lhEhG|g`>pDx-d*KA_$(V$i zEdf8&E2>Y!=GAIzm%<+$-p)o&T{M#%Ph6%a+`44E^rA!-Rl7csuQA}msxgM%?U7(R z%**0n!Uh>difVTuIB~+o4}p!J-Hipm+_37}BQMqIGVz-(uMq*41B{n(Z<=s(aB&l_jea|!Q&DPG!V~j@^wMXNCs`JvF4cj7D3-+?3%{| z-80C6NMkYyCa{=(uA7(Puq3kLTDfM%872o{W8~$|7seRr0tuB* z<0{I&4pv(!Bc};keR(_f6$`^XQw$Gb7#wJWl!IN$}8P3i?q9t zl^Ktk)Ss!Bz_-i&N#2mh4&JfmmeA6{e|~5@l~c%v14t;2FqD5u_Ihc6|1XO{Xq$l; z!fV!B*+&EGFztVCW**q8t<@9H}9c?27BH%l0j~c-WIWWuO5MEMn>r9 z-gBeANGV~e$KF)^QG8R3Z*zK-kW=!ByP%iGQyYMC}@ zp=88-S#%}9@Pd6yRert> zlyfJWGfYx)$tyKyi4BdDsGoJ$%*3s~W`-<%)O#dNAV;E+<=1 zcX3zKOKXGRl`ypudJl(0i|Nk-{PiW)D_1*J&0E zgzh&gcJG0?xH758OTN3ezz1DF#Lk{^4Id(3J+NI7Ve(FHEZk1gZVzjvTI_k;m%QxD zS!Ldx=z7r{!SMn#Gm+%v!_aGP5BDNj)~ZvO4j*U~9CUP269+C!iNSsV#Z4Doryz~v z(ZLmVF#qD-)?i%|E-ndDWnrd<676Ji)}ir*e4doP2R>DUJD0!KiL?wZO6MO*^w4PL z#_d@P6W#Oa%~l>VLtkuFMG?qsR5349UT$FZba$Bk=!?&6JN$jYa z>t3mD3p(IBL$)SK-j-nA$P&FGCl_Y4<{mVnQ;-0t09v!=2LMLooj=pILINriDv$Zo zG1LIW1z3&*+Ln{rQ*%hM$!nK&A8Ot{ytCfUhD#NHM1(zz$IJGn~_u6@q_-*R0_< zH~bWc=nnBtiznU9i5*I%%M#(7Yi^V3$0bp-e?8-$N9YUKEQWninWlr`_P0e~o&Dii zj1;#p1C*g_Ec6?~tw8ao#EJB<&u2d3+;X+g?IglD@sD?zp71pn&vc>2Wgtt9zU3oS zXeQ5>AK8{?cHY26pYXkg<@bjJ#Ffrwx=&fGnH}J@5s0ej5XmDlIP^Xu=$zKW%ZA48 z?{;_83>uLohv^|CU8I=_d6*lrFNvs?~&ChqRslkGIyj9PdUC|iqnaKL4OPacIs<9}0& z&Efd>^kY&!%XRL+C@<;*ptuKPJY+BdY}xj69{i}tW?m}%vkDc9MRS#+M3-YX))o*H z=Tg5i-pY}4f92p0?z%fmH%YIxqM{3q{2pi`d}qkmUmEwq$j%3 z9J&v(Vxt z92TION~wS&zOGXi&NvA;Vr3VUcXfH4Z$))6v6e3rR*(@y$euh7qteM^g>E05aQ-aN zFKweU<>5>`gzLhw^MLDjh;p{of;72X3Q|W-i>2NvGO*jlLlS!UhhL8!7|EMmvJ-tQ zhQDBQSM77uyV>?Me1XcL3IgX6Io1qdRgI zOLa|2-?dHjn&7ggrJTok+5CM+S#y%HG;cEsc5$3RT`|dy8tm$79R=bkK^H*M%>6%U zRU$R3<&x2xOS0E{ThDq0=xs8tqyHH(3c<6o!9}L`H<=^g!{&BSwA-eovu37!s7@=~ z~fS2S5 z#tfd^@oCcM%Da;uc;E?q_}R@T^> zKbIbQF6AdN2lDumIIW9wmaQ5>`g%aV9EN+-V<|fy+-pcd*7O1OqcKu=OX8wHY78yj z_j#s>^O7!4-LC27UTG|@(zG<-gq8^L_(5N?AM)-~eRj0%CRWC?vJ23O**(5;p!_o! z(vWk*8>BYs@~8Rh%)S)91C&>7lJ7BxV}da(j)i#Er8NX3;mY?*3+H`H9MvVxPnCEr=$6>Cf6MjaOpY+hnfw9ft%YkH%r4~*Mo)*N z#O2VZo+Wa*{bmUNmHj{B_IjdW+4;-SwBb@TKl@}+p24Kf6|!SiJ{d08H8mJ z&=UnMI0~+>8(>iEp|)r(dl7Ja7jLsP^GN^kAj3@IEv+oepPc^DiV(b)WWf`Jat!(g7ObCBFzX_7=N(f6qA<(SrMxgfXHW|^^*E|$T z718uPr*3e16z_lj`(mB+_ZlBk7E-cV#ylW5y0Pglb(zbTCej)v zB^d+nyK4V+P+ceB48LbI%6%KO;q_xv*`-y@*B%ltMF86vqqydhiR1#ST;^YZA1w)k zu%AgT0wht)w>(>LJ(73103jJW>MQz2DYq2u*ed{XI06JNm3}8IqtNU1?ot6xA+_MR z-CfokV`J=C{rpk+jjKT|R)2?ZK(TfhhRI zri*sIjFq#;ny?-mK!c39j+dW-zW5P9r2zZl`dF?*%Pm4~ob{bVu0^dYX4Jj?>V=V2 z$c{q}yE%5Q4aZx{8sL^`BXF|Z5@LGs{8d6=Fnkde-j9V^!OKk*%Q{KSL6_J6OyrDv z59~zF#ywsF)RXCK9xAlTDdQgoMN4cN%PL@~Q9Z2XFu*CdOy{wBS_E@C<2g`)-?xE$ z*fcc<*#70f$U%yj;~A(~i!ih{GI5j`=ASu+Vt{-^fwxfXrtDlWJ?oEfhZ7%qc#M?Z z8Q_m5BkMLZ{4iFpW@AW~n}DhS#9Ujd2~lUmk~QG#&35g^D=-wR zOZSpifR);l0zWZR3yvorrN=e+iECdU$We8L1<)w{Jr&`;MlvfK(C^7l#=UjnCdmx4 zMIAWEsPd-`PXQ>W_PJ1~1Vs1T@1j&KeRf*s_tFgsQ5TjGm)?|>6d z1V8EXeP`>)@1E|{dM{}0UmJ_Xdaq>hkU}|haF0WF3YL?cJEKe=%NJMp0oV+1Zz>`@ zCS~bgiHPG1{VaCBddt2-D%6id?Wz%74u5AMx9~REWMU{!!fM1jXN)8}@8UAY91`NA z?trDz)%PCt4~KiWV#jQ?CRV608M|P4w73r`Z&1^884M^vRgY#aA_7WK%8!MKFo@+C zl!buO$wO%`Ehx9m9%oCT=vJT;OV>IpXp|F%%Hrg#Q34|A5yDyRWhy8|YX{#qUOd0+>$U?Ig-?1{8O1 zc}nAiRk*sTNTN2V_P|wig_P1@FVpfrTgP9GturONw{=Urh!B!~&zzNo)cBBu22?B=Y_8 zJz^o-UwXK@EIHgfpV{a+w=-!`O{ZBE(zU#lM{xCaR<{i8cCrvMhUUMT2R~w6)9_>K6wNXC(3w7 zW_Z$m-Y!m|`~1?s^1U2zd3NOu`rGX-`Od!^+<&Jq&TmieO(8??d^F6xg9XE(tTK$( z2>Gt)o3y#Tu&o-Zu3hD*a|%M4odB5q^3Tb|VL$$QSgl%TA!)3P=4_<)7x^9}ehk+{ z@se&*1a82)Z~dZe=fI?$Aa;89rDQx;W#W#{1_(J_LWxTSxCkIKe8{19u%-~*)r14#bazsL?9QHNk^tK5Oxl@F zz3C`sNP;(N%0OMIrG+{FjV`8yO{yH{k8D1}*uJXRDG=cmhP zT=TQskeD6csz+PV>{b~mU#)N#ruQZ_wpoK>K5RU^G2j*t#^&e{IG4#s`}AbsC~%(? z%rm#fY>R)W-w=!b<7quRoI8sRhiVX_{l&bwSmn`3D5Q&amWR_9;SPUY4ZH**G};Hj zT<(_Zir(3cC#=+pLm1m&@TX%wF#xP%=(*o@4y&E(l^+=mGMFZ2Nj|0#a*Oi{y>v8q zgycBP@4@Cg-)-A`fAAq&iFP^-`G`P3AY_7R8(U^fJ)&+dhXp=%ZuD>NvXK|9p;K@n zEGR47F$;pB1M#@^y?1jp@Dl}1+rc^ul<_V}=hAH9hPGC8)Wxnj{y>2=oCcR!&$G||YE$ef<>&bL%vr|lDLvW@i?zW}j!Ky7~_E~=DyJa+? z%Bg0B?_7x*$=-Ec0;E&Pby$U08VeqSBC7=pjFolr0s7%^PQ82{{d`oX=lvQ&GXhwW zF~_?&OT{HQuQvCLkWn}8r&#Kok6vdQ3Wb{VeV<;y>lG#v@i0&z!y_xYM zU13#;+Hyg1ti-r7(Y0DhDN;B9AgF0@E-&V!L0m6M7cLMf366`YN3*_q5u1m+fs#NR zD`QQvxf^HG6tj>(g}4vM`Le&`4oJjGF!lRkW>gC9m`{;RI&=c_dKUQ~peUWmK(5y6 zKk?8x+!-$%8uRo8ErMJ( z5VYm$vOE0&0W$Qj_)u~~1aiPi7_)Sx{jg&$Y-1dKP~H3}tdyAN>xCLcXc%!K1I(g+ zG|ZJ>(M)I)d>5W-)SK$ zqWQ6k`C*yF;k(pzVd)qN=6@8+u?!@!HOSXG>Lbx8ps479z@4V@W~Zbmopbh%7bG{^ zw@o|HJ-oV8VVj}Mz<$3l#uc(%P|f-rl|w49AJA#J5zQ2Dth4!g$VwCzPtKl7@!Ltd z%&(@<{EFTFlRB<~bYxki_Uf_=1MwV;=KOI!Kpu+I2Hq$0B~JGO-Q~u&0t@(f`R>fx zQZrH{;(}j#8uX1l5O7~srWHxp6MV(P024TJ+|chd@ar*u#r#1v>SyZ!{H+hkD`AY}q#efn#zx zO*{-`b@DY{Ek!VPj_dm@TTm;6`_UA*L58T3O@d#r7m4CKd*t@(pStz5{ha%ERkQHPxlMA9yqH?1Rpq*1 zm4bw#CR2&Y;hx|pV3pWE=VX=fwcCByK;th8IgW}aA=>W&wDNI1M3%ii&;>6qW4kjO zWyFFjqiP7nuOKN;Pz>)kjW^3}x0R$kkiD~Vp}&Za#s#ivw_h5Y`F3Sl@aCf?TDYj zSb!T=HyT$wP(M2wi3O{~+X^r^ItA*E`=}Ynjs5PHV)=p}lVm=Uk`Zfr6_Ew6rl)UI zRaVc4wGPTpSlTMTGka8_>#Gpf5!I5-z0f)y9(*cu;F%4|f!_|iUg#9XYs%NFeX63fafIQIBS4TFmiU!i%5ojK-?}yPWU>8zN6~BkKV$!yTxLEd{!6St z4kLm#T&)g(7aEQ1W2DbV&b>g8ScPn~&F4zv>-L6UK$lcGk(J#7J<%B-mrX%4JGr^9 zH9Ali$j_55j6@D@=dBeNM)d#{f-2QyHNf7+Bkt+r)>X6Y2rRnV(L+MLM)v0;-NaVW z5r6|^c1Sw=svy}6-_L?bq|>X)2$b^hx9(D4x}!V%>9c4nbKn`hf-tGUzHSF{iyKTe zXO4f?5y`elV=NU!_VkJpX{q>&ok`2DbiVYz8b)$tV@5!7FutFMmTjG;Do@C&_ZtgE z6ZZXqP91?Ej1zr36)H}$*igzO8MyTv4z2bU!%U#^%`sqV8H*AjX z-KR|uXWT9+iByh?PR`X!dw@%Lhd_`Yln_Sr@vt;C(bKYtY(v47DK{Ee;eT<_l_GLJ z1~wF&l~-}Riy^(<0LoP@19Y&;uOR@G!>9&VLv{K@+Do$|GH0#WtkqX{d^zB_pHe{r z)#d(aC(7?L@Q|>PL2$IIRqwMW?u>_a^1O?Y?G?qhiMlHAqkzIFQP%*>i461UW0`}> z5bcj=VJQN@lr78aCYq;SXiHxAW#Lt-wtKPj0>_<0&uJw-mEB+46r3|6BKYXA^e2DW z(7{`Dts0ayjB%xI#rqW02rN!FGG_9^r9KE!rtzy@RnfCykWu;Tf65^tB`hBt%rf`a z&bD9X9uPqst0VFjBi_ti+K5R2hKVVoEB`ZOBA{qQH-|=WlhHzGX!RwIalSbcT8FG; zEm=HS?7%tAP(qjO1$}x<_dfl_omq5*vEbWCNuH>aJ3FiCEb;~%KLSu|u523>7zMoS zLVUvoP?5XKr=VKLk-ul_=sWb<1zW!pPcabs@h`|BWlG9An!H52d&_IHr!<IJsS5OtJEegBg{vMssiw;p4k7*$5gd zW$UZ$=W?q`YHudDnK$$Xvj6QI(4gk|6an$V)cM^IX$9X46Ps*{2<*?immmG1PjCM` zyRt+xf#jcv%OmoI&GSKnu=AkYKkwCyS0EcE0u#u&DgPe5k}PXVLSH8gSaS6vrb`xb zeh>ZkdrSk)HoLg{?428%wMgbUE8!J8QZEXF~4kG}oVgGaO21=-NZ z`f*ZVWSwA&1A-|=HRM13!TL0UTmvEL4qKTvA^{IgURC^gL*i3xOL-j#hq84>S}-1> zKe6-~`c|q5=g!F=EZ%cE_T^9J>6) z?T&#SU$}mqlCULd+Gn&&X+S{2ejaFmwM2C~XqT(+FFw O054Rum8+gvNBj@C@D1Mp literal 0 HcmV?d00001 diff --git a/src/TemporalCollections/TemporalCollections.csproj b/src/TemporalCollections/TemporalCollections.csproj index 1077ccd..975df46 100644 --- a/src/TemporalCollections/TemporalCollections.csproj +++ b/src/TemporalCollections/TemporalCollections.csproj @@ -12,6 +12,7 @@ Thread-safe .NET collections with built-in, monotonic timestamps and a unified API for fast range queries, pruning, and temporal analytics. https://github.com/engineering87/TemporalCollections README.md + icon.png https://github.com/engineering87/TemporalCollections git LICENSE @@ -28,6 +29,10 @@ True \ + + True + \ +