Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions .agents/skills/foundatio/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,18 +308,18 @@ 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. Key writes remove the key; `ListAddAsync` removes only the supplied values. Guard dynamically computed TTLs 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. In-memory `AddAsync`/`SetAsync`/`ReplaceAsync` that expire during value preparation return false without deleting a live entry; expired stored entries are still reclaimed and raise `ItemExpired`; this differs from explicitly requesting a TTL below the floor.
- **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. `ReplaceIfEqualAsync` also rejects a replacement that expires during preparation or a retry, preserving a still-live entry.
- **Custom `SizeCalculator` and lists**: runs while a per-key-stripe lock may be held during contended list writes, so it must not call back into the cache. It receives a `Dictionary<T, DateTime?>` copy of the whole list on each write; the built-in `WithDynamicSizing`/`WithFixedSizing` avoid that copy.
- **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; this also applies when a throwing custom calculator produces an oversized fallback estimate. `ReplaceIfEqualAsync` also rejects a replacement that expires during preparation or a retry, preserving a still-live entry.
- **Custom `SizeCalculator` and lists**: runs while a per-key-stripe lock may be held during contended list writes, so it must not call back into the cache. By default it receives a `Dictionary<T, DateTime?>` copy of the whole list on each write. Interface-based calculators that never mutate inputs can opt in via `SizeCalculator(calculateSize, supportsReadOnlyCollections: true)` to avoid that copy for immutable lists; both ordinary dictionaries and read-only views must be supported. Built-in `WithDynamicSizing`/`WithFixedSizing` already avoid the copy.
- **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.
- **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. Cloned dictionary reads preserve incremental list writes; uncloned dictionary reads expose a shared copy and require subsequent writers to copy it. 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. Adding existing list items still reapplies their resulting expiration, even when the list contents are unchanged.
- **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 Down
7 changes: 4 additions & 3 deletions benchmarks/InMemoryListBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ namespace Foundatio.Benchmarks;
[MemoryDiagnoser]
public class InMemoryListBenchmarks
{
public enum SizingMode { None, Fixed, Custom, Dynamic }
public enum SizingMode { None, Fixed, Custom, CustomReadOnly, 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)]
[Params(SizingMode.None, SizingMode.Fixed, SizingMode.Custom, SizingMode.CustomReadOnly, SizingMode.Dynamic)]
public SizingMode Sizing { get; set; }

private InMemoryCacheClient _cache = null!;
Expand All @@ -36,7 +36,8 @@ public async Task Setup()
return Sizing switch
{
SizingMode.Fixed => o.WithFixedSizing(100000000, 100),
SizingMode.Custom => o.MaxMemorySize(100000000).SizeCalculator(_ => 100),
SizingMode.Custom => o.MaxMemorySize(100000000).SizeCalculator(value => ((IDictionary<int, DateTime?>)value).Count),
SizingMode.CustomReadOnly => o.MaxMemorySize(100000000).SizeCalculator(value => ((IDictionary<int, DateTime?>)value).Count, supportsReadOnlyCollections: true),
SizingMode.Dynamic => o.WithDynamicSizing(100000000),
_ => o
};
Expand Down
5 changes: 4 additions & 1 deletion docs/guide/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,11 +273,14 @@ Successful removal of a live matching value, or removal of the last list value,
- `ListAddAsync` returns `0` and leaves the list unchanged when the merged list fails size validation.
- `Items` returns the cached values rather than the internal entry objects.
- `IncrementAsync` recalculates the entry's tracked size after each increment.
- If an accepted TTL elapses while `AddAsync`, `SetAsync`, or `ReplaceAsync` prepares its value (for example, during sizing or cloning), the write returns `false` without deleting an existing live entry. An expired stored entry is still reclaimed and raises `ItemExpired`. This differs from explicitly supplying a TTL below 5ms, which still requests removal.
- If a custom `SizeCalculator` throws, the fallback estimate is also checked against `MaxEntrySize`. An oversized fallback now rejects the write (or throws `MaxEntrySizeExceededCacheException` when configured), preserving the previous entry. Earlier versions bypassed this limit on the fallback path.
- `ReplaceAsync` on an expired key returns `false` and raises `ItemExpired` once while reclaiming it.
- `ReplaceIfEqualAsync` returns `false` if the replacement expires during preparation or a retry, preserving any still-live entry's value, expiration, and tracked size.
- `CurrentMemorySize` is approximate while `RemoveAllAsync()` or `Dispose` races concurrent writes. This is unchanged from earlier versions; it self-corrects on the next recount after compaction.
- List size limits (`MaxEntrySize`, a negative `SizeCalculator` result) apply to the whole resulting list on every `ListAddAsync` and `ListRemoveAsync`, so a removal that leaves the list over the limit is rejected and returns `0`.
- A rejected `ListAddAsync` on an expired key still reclaims the expired entry and raises `ItemExpired`, and every `ListAddAsync` call counts as a write in `Writes`, including rejected ones.
- Adding items reapplies the resulting list expiration, even when every supplied item is already present with the same TTL. This can replace a key expiration previously set with `SetExpirationAsync`.
- List item expirations read back with `DateTimeKind.Utc`, whatever kind was passed in.
:::

Expand All @@ -286,7 +289,7 @@ List updates preserve the collection structure of previously returned snapshots.
`Items` returns cached values without changing eviction order. Both `Items` and ordinary reads honor `CloneValues`. With cloning disabled, mutable payloads and list elements can remain shared: a collection snapshot is not a deep copy. Do not mutate shared objects concurrently with cache operations.

::: tip Large list updates
Batch values into a single `ListAddAsync` or `ListRemoveAsync` call. Lists over 32 items are stored as immutable snapshots, so a small change copies only the parts it touches, including with `WithDynamicSizing` or `WithFixedSizing`. A custom `SizeCalculator` still receives a `Dictionary<T, DateTime?>` copy of the whole list on each write. With `CloneValues` on, snapshots apply to lists of strings and of value types without reference fields; other lists are copied on each write. Raw dictionary access (`GetAsync<IDictionary<T, DateTime?>>`) materializes a copy the first time it's read. Benchmark representative list sizes and read/write patterns before choosing these options; atomic updates do not make every workload allocation-free.
Batch values into a single `ListAddAsync` or `ListRemoveAsync` call. Lists over 32 items are stored as immutable snapshots, so a small change copies only the parts it touches, including with `WithDynamicSizing` or `WithFixedSizing`. A custom `SizeCalculator` receives a `Dictionary<T, DateTime?>` copy of the whole list on each write by default. Calculators that use collection interfaces can opt in to read-only views with `SizeCalculator(calculateSize, supportsReadOnlyCollections: true)` (or `SizeCalculatorSupportsReadOnlyCollections` on the options). They must accept ordinary dictionaries and read-only dictionary views and must not modify either. This avoids the sizing copy for immutable lists; it does not change cache reads, element cloning, or the cost of enumerating all items. Sampling `IReadOnlyDictionary.Keys` or `.Values` enumerates lazily. Lists that shrink substantially rebuild their sparse storage on a subsequent write; that occasional full copy prevents ongoing costs from remaining tied to the historical peak size. Prefer `WithFixedSizing` for a constant estimate. With `CloneValues` on, snapshots apply to lists of strings and of value types without reference fields; other lists are copied on each write. Raw dictionary access (`GetAsync<IDictionary<T, DateTime?>>`) with cloning disabled materializes a shared dictionary and makes subsequent writes copy it. With cloning enabled, dictionary reads and `Items` return independent copies without disabling incremental writes. Benchmark representative list sizes and read/write patterns before choosing these options; atomic updates do not make every workload allocation-free.
:::

### HybridCacheClient
Expand Down
Loading
Loading