Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
abd13b0
Fix InMemoryCacheClient removing live entries during maintenance
niemyjski Sep 22, 2026
39a0246
Stop list operations mutating published payloads and remove double clone
niemyjski Sep 22, 2026
746eb59
Fix AddAsync reporting failure after adding, and keep compaction goin…
niemyjski Sep 22, 2026
9d80c72
Route every InMemoryCacheClient write through one UpdateEntry helper
niemyjski Sep 22, 2026
87fc066
Keep ReplaceIfEqualAsync from sizing mismatched values and clone adde…
niemyjski Sep 22, 2026
d207f44
Reject renewal and release of expired or lost cache locks
niemyjski Sep 23, 2026
f06ec7c
Harden cache size rejection and lease renewal boundaries
niemyjski Sep 24, 2026
b179d3e
Avoid redundant list copies and repeated maintenance clock reads
niemyjski Sep 24, 2026
8321973
Preserve dictionary comparers and types when copying list values
niemyjski Sep 24, 2026
c1c9c7a
Fix reference tracking for cyclic collection storage
niemyjski Sep 24, 2026
7ce6eb1
Share unchanged large-list storage for atomic cache updates
niemyjski Sep 24, 2026
6a77e97
Cover large-list expiration and dynamic sizing benchmarks
niemyjski Sep 24, 2026
589d686
Move collection cycle repair upstream to FastCloner
niemyjski Sep 26, 2026
c9197c1
Reclaim expired conditional cache entries and preserve task failures
niemyjski Oct 2, 2026
1ca207a
Share renewal ownership regressions and clarify paused-worker recovery
niemyjski Oct 2, 2026
a58512b
Preserve conditional expiry boundaries and list comparer contracts
niemyjski Oct 5, 2026
84ffb36
Separate lost-lock renewal behavior into a dependent PR
niemyjski Oct 5, 2026
46f3aad
Restore shared throttle coverage and test a waiting caller with fake …
niemyjski Oct 6, 2026
48f5404
Replace callback-clock tests with deterministic cache behavior coverage
niemyjski Oct 6, 2026
a5f4b6e
Preserve the original cache-key assertion during test cleanup
niemyjski Oct 6, 2026
1d0c0b0
Correct caching guide contracts, cloning examples, and list expiration
niemyjski Oct 6, 2026
fc69a98
Align cache guidance with fake-time tests and preserved contracts
niemyjski Oct 6, 2026
8812267
Correct cache configuration examples and document fake-time testing
niemyjski Oct 6, 2026
0aad3f7
Restore the unrelated file existence example in cache guidance cleanup
niemyjski Oct 6, 2026
e918bd5
Reproduce compaction budget exhaustion with fake-time regression tests
niemyjski Oct 6, 2026
a677822
Preserve bounded compaction behavior and remove log-driven test hooks
niemyjski Oct 6, 2026
83b1bed
Treat expired in-memory entries as missing on every write and stop lo…
niemyjski Oct 6, 2026
9f6aa37
Merge remote-tracking branch 'origin/main' into fix/in-memory-cache-i…
niemyjski Oct 6, 2026
df7a724
Remove per-write allocation from UpdateEntry and skip ItemExpired wor…
niemyjski Oct 6, 2026
ff851af
Restore main's two maintenance tests verbatim
niemyjski Oct 6, 2026
9eb6aad
Explain why the UpdateEntry retry loop always terminates, including d…
niemyjski Oct 7, 2026
ae789c3
Keep expired entries in the memory recount, decide lease reclaim once…
niemyjski Oct 7, 2026
a7d96ed
Flow the test logger into every cache these tests create and use one …
niemyjski Oct 7, 2026
351d2cb
Merge current main dependency patches into cache review
niemyjski Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .agents/skills/foundatio/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ Use `Foundatio.Xunit.v3` for test logging and DI integration. Two base classes:
- **`TestWithLoggingBase`** -- lightweight, no DI container. `_logger` (`ILogger`) for logging; `Log` (`ILoggerFactory`) for passing to Foundatio services.
- **`TestLoggerBase`** -- full DI via `TestLoggerFixture`. Override `ConfigureServices` to register services. `Log` (`ILogger`) for logging; `TestLogger` (`ILoggerFactory`) for passing to Foundatio services.

Use `Microsoft.Extensions.Time.Testing.FakeTimeProvider` for expiration and timer tests. Inject the same instance into collaborating caches and lock providers, and advance it explicitly. A real-time timeout may bound an awaited completion, but should not simulate cache expiration. Keep shared test-harness coverage when adding provider-specific fake-time tests. Test observable behavior rather than the number of clock reads.

```csharp
using Foundatio.Caching;
using Foundatio.Xunit;
Expand Down Expand Up @@ -306,14 +308,17 @@ public class OrderServiceTests : TestLoggerBase

- **Lock returns null**: `TryAcquireAsync` returns `null` when the lock cannot be acquired -- always guard with `is not null` before doing work. `AcquireAsync` throws `LockAcquisitionTimeoutException` instead of returning null.
- **Dispose streams and locks**: `ILock` is `IAsyncDisposable` -- use `await using`. Streams from `GetFileStreamAsync` are `IDisposable` -- use `using var`.
- **Cache TTL floor**: Expiration values below 5ms are treated as already-expired and the key is silently removed. If you compute TTL dynamically (e.g., `expiresAt - now`), guard against near-zero values.
- **Cache TTL floor**: Expiration values below 5ms are treated as already expired. Key writes remove the key; `ListAddAsync` removes only the supplied values. Guard dynamically computed TTLs against near-zero values.
- **Cache `GetAsync` returns `CacheValue<T>`**: Check `result.HasValue` before accessing `result.Value`. A missing key returns `HasValue = false`, not an exception.
- **Cache stampede (thundering herd)**: The cache-aside pattern (`Get` -> miss -> load -> `Set`) is vulnerable to stampedes when a popular key expires and many callers regenerate simultaneously. Use `CacheLockProvider` to serialize regeneration: acquire a lock keyed on the cache key, double-check the cache after acquiring, and only then call the backing store. See the [Cache Stampede Protection](https://foundatio.readthedocs.io/guide/caching.html#cache-stampede-protection) docs for the full pattern.
- **Queue auto-complete**: `QueueJobBase<T>` auto-completes entries based on `JobResult` by default. Set `AutoComplete = false` only when you need manual `CompleteAsync()`/`AbandonAsync()` control. Manual `DequeueAsync` does NOT auto-complete.
- **GetQueueEntryLockAsync error handling**: If `GetQueueEntryLockAsync` returns `null`, the queue entry is abandoned. If it throws, the entry is also abandoned and a `JobResult.FromException` is returned. Use `TryAcquireAsync` (not `AcquireAsync`) in your override since the return type is `Task<ILock?>`.
- **Failure semantics depend on job type**: `JobResult` only has `IsSuccess` -- there is no separate "failed but don't retry" status. For **queue-processed jobs** (`QueueJobBase<T>.ProcessQueueEntryAsync`, or setting `context.Result` in a `WorkItemJob` handler), a non-success result triggers `AbandonAsync`, which re-queues the entry and eventually dead-letters it after `Retries` is exhausted -- reserve `FailedWithMessage`/`FromException` for transient errors you want retried, and log + return `JobResult.Success`/`SuccessWithMessage(...)` for permanent errors to avoid a pointless retry loop. For **standalone/manual jobs** (`JobBase`, a one-off `RunAsync()`/`RunInConsoleAsync()` run, or scheduled/cron jobs via `Foundatio.Extensions.Hosting`), there is no built-in retry or dead letter queue -- a failed result just produces an error-level log, a non-zero exit code from `RunInConsoleAsync`, or a failed entry in the job run history. Returning `FailedWithMessage`/`FromException` there is correct even for permanent errors, since nothing inside Foundatio will retry it.
- **JobWithLockBase vs manual locking**: Use `JobWithLockBase` when the entire run must be single-instance (leader election). Use manual `ILockProvider.AcquireAsync` inside `JobBase` for finer-grained locking within a job.
- **JobContext.RenewLockAsync**: Call in long-running jobs (both `JobBase` and `QueueJobBase`) to prevent lock expiration mid-processing.
- **In-memory expired entries**: Writes treat an expired entry as missing before maintenance sweeps it (as Redis does): increments and `SetIfHigher`/`SetIfLower` start fresh, `ReplaceAsync`, `SetExpirationAsync`, `RemoveIfEqualAsync` and `ReplaceIfEqualAsync` miss. The write reclaims the expired entry and raises `ItemExpired` once. Successful live conditional removal and empty-list removal free memory without that event. Size-rejected updates preserve value, TTL, and tracked size.
- **In-memory numeric TTLs**: `SetIfHigherAsync` and `SetIfLowerAsync` apply the requested expiration even when the numeric value is unchanged. A zero return value does not mean the TTL was preserved; `null` clears it. Size-rejected updates remain unchanged.
- **In-memory lists and cloning**: List writes preserve supported dictionary types and comparers; copying an unsupported `IDictionary` throws `NotSupportedException` without changing the entry. Batch large updates because cloning, custom sizing, and raw dictionary access can require whole-list copies. `Items` honors `CloneValues` without affecting LRU. Collection snapshots do not isolate shared mutable elements when cloning is disabled. See the [caching guide](https://foundatio.readthedocs.io/guide/caching.html#in-memory-update-behavior) for details.
- **Register as singletons**: All infrastructure services (`ICacheClient`, `IMessageBus`, `IQueue<T>`, `IFileStorage`, `ILockProvider`) maintain internal state and connections -- always register as singletons.
- **CacheLockProvider + IMessageBus**: `IMessageBus` is optional but recommended. Without it, lock release falls back to polling. With it, locks are released instantly via pub/sub notification.
- **In-memory for tests**: In-memory implementations follow the same interfaces as production providers, but some behaviors differ (delivery delay limits, ordering, deduplication, group ids). Swap via DI for fast, isolated unit tests, and check [provider behavioral gaps](https://foundatio.dev/guide/provider-behavioral-gaps) before relying on provider-specific behavior.
Expand All @@ -331,7 +336,7 @@ public class OrderServiceTests : TestLoggerBase

### Serializers

`ITextSerializer` extends `ISerializer` for human-readable formats (JSON). `ISerializer` covers binary formats. Default is `SystemTextJsonSerializer` (included in core).
`ITextSerializer` extends `ISerializer` for human-readable formats (JSON). `ISerializer` covers binary and text serialization. Default is `SystemTextJsonSerializer` (included in core).

| Package | Provides |
| ------- | -------- |
Expand Down
107 changes: 107 additions & 0 deletions benchmarks/InMemoryListBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using Foundatio.Caching;

namespace Foundatio.Benchmarks;

[MemoryDiagnoser]
public class InMemoryListBenchmarks
{
public enum SizingMode { None, Fixed, Custom, Dynamic }

[Params(false, true)]
public bool CloneValues { get; set; }

[Params(10, 1000)]
public int Count { get; set; }

[Params(SizingMode.None, SizingMode.Fixed, SizingMode.Custom, SizingMode.Dynamic)]
public SizingMode Sizing { get; set; }

private InMemoryCacheClient _cache = null!;
private int[] _initial = null!;
private int[] _batch = null!;
private readonly int[] _item = [-1];
private readonly int[] _workers = [0, 1, 2, 3];

[GlobalSetup]
public async Task Setup()
{
_cache = new InMemoryCacheClient(o =>
{
o.CloneValues(CloneValues).TimeProvider(new FixedTimeProvider());
return Sizing switch
{
SizingMode.Fixed => o.WithFixedSizing(100000000, 100),
SizingMode.Custom => o.MaxMemorySize(100000000).SizeCalculator(_ => 100),
SizingMode.Dynamic => o.WithDynamicSizing(100000000),
_ => o
};
});
_initial = Enumerable.Range(0, Count).ToArray();
_batch = Enumerable.Range(0, Count / 2).ToArray();
await _cache.ListAddAsync("set", _initial);
}

[GlobalCleanup]
public void Cleanup() => _cache.Dispose();

[Benchmark]
public async Task<long> AddRemove()
{
await _cache.ListAddAsync("set", _item);
return await _cache.ListRemoveAsync("set", _item);
}

[Benchmark]
public async Task<long> BulkInsert()
{
await _cache.RemoveAsync("bulk");
return await _cache.ListAddAsync("bulk", _initial);
}

[Benchmark]
public async Task<long> BulkRemoveRestore()
{
await _cache.ListRemoveAsync("set", _batch);
return await _cache.ListAddAsync("set", _batch);
}

[Benchmark]
public Task ContendedWrites() => Parallel.ForEachAsync(_workers, async (worker, token) =>
{
int[] item = [-worker - 1];
await Task.Yield();
await _cache.ListAddAsync("set", item);
await _cache.ListRemoveAsync("set", item);
});

[Benchmark]
public async Task<long> FirstModification()
{
await _cache.RemoveAsync("cold");
await _cache.ListAddAsync("cold", _initial);
return await _cache.ListAddAsync("cold", _item);
}

[Benchmark]
public Task<CacheValue<ICollection<int>>> Read() => _cache.GetListAsync<int>("set");

[Benchmark]
public Task<long> RemoveAbsent() => _cache.ListRemoveAsync("set", _item);

[Benchmark]
public async Task<CacheValue<ICollection<int>>> WriteThenRead()
{
await AddRemove();
return await _cache.GetListAsync<int>("set");
}

private sealed class FixedTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
}
40 changes: 40 additions & 0 deletions benchmarks/InMemoryWriteBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using Foundatio.Caching;

namespace Foundatio.Benchmarks;

/// <summary>
/// Scalar writes on a key that holds a live value, which is the common case. Every write goes through
/// <c>UpdateEntry</c>, so these show what its wrapper costs.
/// </summary>
[MemoryDiagnoser]
public class InMemoryWriteBenchmarks
{
private InMemoryCacheClient _cache = null!;

[GlobalSetup]
public async Task Setup()
{
_cache = new InMemoryCacheClient(o => o.CloneValues(false));
await _cache.SetAsync("key", 1L);
}

[GlobalCleanup]
public void Cleanup() => _cache.Dispose();

[Benchmark]
public Task<bool> Set() => _cache.SetAsync("key", 1L);

[Benchmark]
public Task<bool> Add_ExistingKey() => _cache.AddAsync("key", 1L);

[Benchmark]
public Task<long> Increment() => _cache.IncrementAsync("key", 1L);

[Benchmark]
public Task<long> SetIfHigher_NotHigher() => _cache.SetIfHigherAsync("key", 0L);

[Benchmark]
public Task<bool> ReplaceIfEqual() => _cache.ReplaceIfEqualAsync("key", 1L, 1L);
}
Loading
Loading