--- url: /guide/caching.md --- # Caching Caching allows you to store and access data lightning fast, saving expensive operations to create or get data. Foundatio provides multiple cache implementations through the `ICacheClient` interface. ## The ICacheClient Interface [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Caching/ICacheClient.cs) ```csharp public interface ICacheClient : IDisposable { // Key operations Task RemoveAsync(string key); Task RemoveIfEqualAsync(string key, T expected); Task RemoveAllAsync(IEnumerable? keys = null); Task RemoveByPrefixAsync(string prefix); Task ExistsAsync(string key); // Get operations Task> GetAsync(string key); Task>> GetAllAsync(IEnumerable keys); // Set operations Task AddAsync(string key, T value, TimeSpan? expiresIn = null); Task SetAsync(string key, T value, TimeSpan? expiresIn = null); Task SetAllAsync(IDictionary values, TimeSpan? expiresIn = null); Task ReplaceAsync(string key, T value, TimeSpan? expiresIn = null); Task ReplaceIfEqualAsync(string key, T value, T expected, TimeSpan? expiresIn = null); // Numeric operations Task IncrementAsync(string key, double amount, TimeSpan? expiresIn = null); Task IncrementAsync(string key, long amount, TimeSpan? expiresIn = null); Task SetIfHigherAsync(string key, double value, TimeSpan? expiresIn = null); Task SetIfHigherAsync(string key, long value, TimeSpan? expiresIn = null); Task SetIfLowerAsync(string key, double value, TimeSpan? expiresIn = null); Task SetIfLowerAsync(string key, long value, TimeSpan? expiresIn = null); // Expiration operations Task GetExpirationAsync(string key); Task> GetAllExpirationAsync(IEnumerable keys); Task SetExpirationAsync(string key, TimeSpan expiresIn); Task SetAllExpirationAsync(IDictionary expirations); // List operations Task ListAddAsync(string key, IEnumerable values, TimeSpan? expiresIn = null); Task ListRemoveAsync(string key, IEnumerable values); Task>> GetListAsync(string key, int? page = null, int pageSize = 100); } ``` ## Expiration (TTL) Behavior Many cache methods accept an optional `expiresIn` parameter that controls the TTL (Time-To-Live) of cached items. Understanding its behavior is critical for correct cache usage. ### Quick Reference | `expiresIn` Value | Behavior | |-------------------|----------| | `null` | Entry will not expire. **Removes any existing TTL** on the key. | | Positive `TimeSpan` ≥ 5ms | Entry expires after the specified duration from now. | | Greater than 0 and less than 5ms | **Treated as already expired.** Key is removed, operation returns failure value. See [Minimum Expiration](#minimum-expiration) below. | | Zero or negative | **Treated as already expired.** Key is removed, operation returns failure value. | | `TimeSpan.MaxValue` | Entry will not expire (equivalent to `null`). | ### Minimum Expiration Foundatio enforces a **minimum expiration of 5 milliseconds** (`CacheClientExtensions.MinimumExpiration`) on all cache operations. Any `expiresIn` value greater than zero but less than 5ms is treated as already-expired: the key is removed and the operation returns its failure value. **Why 5ms?** External cache providers—most notably Redis—represent TTLs as integers. StackExchange.Redis converts a `TimeSpan` to milliseconds via `(long)timeSpan.TotalMilliseconds`, which **truncates** the fractional part. A TTL of 0.9ms becomes `0`, and Redis rejects `SET key PX 0` with: ``` ERR invalid expire time in 'setex' ``` This truncation-to-zero can happen legitimately in production when computing `expiresAtUtc - DateTime.UtcNow` on a time very close to "now"—a common race condition in high-throughput systems. The 5ms floor provides a safe margin above the 1ms truncation boundary while remaining far below any real-world cache TTL. **Behavior summary:** ```csharp // Below threshold: treated as expired (key is removed, returns false/0) await cache.SetAsync("key", value, TimeSpan.FromTicks(1)); // 100ns < 5ms → expired await cache.SetAsync("key", value, TimeSpan.FromMilliseconds(3)); // 3ms < 5ms → expired // At threshold: accepted await cache.SetAsync("key", value, TimeSpan.FromMilliseconds(5)); // 5ms == 5ms → succeeds // Above threshold: accepted await cache.SetAsync("key", value, TimeSpan.FromMilliseconds(100)); // 100ms > 5ms → succeeds ``` The constant is accessible for consumers that need to validate TTLs before calling cache methods: ```csharp using Foundatio.Extensions; if (myExpiration < CacheClientExtensions.MinimumExpiration) { // TTL too short; skip the cache operation or use a longer TTL } ``` ### TTL Behavior by Method Different methods handle the `expiresIn` parameter slightly differently. The table below shows exactly what happens for each method: | Method | `null` expiresIn | Positive expiresIn | Zero/Negative | Return on Failure | |--------|------------------|-------------------|---------------|-------------------| | `SetAsync` | No TTL (removes existing) | Sets TTL | Removes key | `false` | | `AddAsync` | No TTL | Sets TTL | Removes key | `false` | | `SetAllAsync` | No TTL (removes existing) | Sets TTL | Removes all keys | `0` | | `ReplaceAsync` | No TTL (removes existing) | Sets TTL | Removes key | `false` | | `ReplaceIfEqualAsync` | No TTL (removes existing) | Sets TTL | Removes key | `false` | | `IncrementAsync` | No TTL (removes existing) | Sets/updates TTL | Removes key | `0` | | `SetIfHigherAsync` | No TTL (removes existing)\* | Sets TTL\* | Removes key | `0` | | `SetIfLowerAsync` | No TTL (removes existing)\* | Sets TTL\* | Removes key | `0` | | `ListAddAsync` | No TTL | Sets TTL | Removes key | `0` | \* **Conditional operations**: `SetIfHigherAsync` and `SetIfLowerAsync` only update TTL when the condition is met. If the value is not higher/lower, the entire operation is a no-op (including expiration). ::: tip ListRemoveAsync `ListRemoveAsync` does not accept an `expiresIn` parameter. It simply removes values from the list without modifying the key's expiration. ::: ::: info Integer vs Floating-Point Increments `IncrementAsync` supports both integer (`long`) and floating-point (`double`) amounts. Both overloads work correctly with expiration: ```csharp // Integer increments await cache.IncrementAsync("counter", 1L, TimeSpan.FromHours(1)); // long overload await cache.IncrementAsync("counter", 5L, TimeSpan.FromHours(1)); // Floating-point increments await cache.IncrementAsync("score", 1.5, TimeSpan.FromHours(1)); // double overload await cache.IncrementAsync("score", 2.25, TimeSpan.FromHours(1)); // Total: 3.75 // Mixed increments work correctly await cache.IncrementAsync("mixed", 1, TimeSpan.FromHours(1)); // 1 await cache.IncrementAsync("mixed", 1.5, TimeSpan.FromHours(1)); // 2.5 await cache.IncrementAsync("mixed", 2, TimeSpan.FromHours(1)); // 4.5 ``` For Redis implementations, integer amounts (including `2.0` where the fractional part is zero) use the more efficient `INCRBY` command, while fractional amounts use `INCRBYFLOAT`. ::: ### Detailed Examples ```csharp // Basic Set Operations // No expiration - item lives until explicitly removed await cache.SetAsync("permanent-key", value); // null is default await cache.SetAsync("also-permanent", value, null); // explicit null // Expires in 30 minutes await cache.SetAsync("session", data, TimeSpan.FromMinutes(30)); // Never expires (equivalent to null) await cache.SetAsync("config", settings, TimeSpan.MaxValue); // Zero/negative = expired, key removed, returns false var success = await cache.SetAsync("invalid", value, TimeSpan.Zero); // false var alsoFails = await cache.SetAsync("invalid", value, TimeSpan.FromSeconds(-1)); // false // Increment Operations (TTL Behavior) // Create counter with TTL await cache.SetAsync("counter", 0, TimeSpan.FromMinutes(5)); // Increment with null removes TTL (consistent with SetAsync) await cache.IncrementAsync("counter", 1, null); // TTL removed! // Increment with explicit TTL sets it await cache.IncrementAsync("counter", 1, TimeSpan.FromMinutes(10)); // TTL now 10 min // Zero/negative removes key, returns 0 var result = await cache.IncrementAsync("counter", 5, TimeSpan.Zero); // 0 // SetIfHigher/SetIfLower (TTL Removal) // Create with TTL await cache.SetAsync("max-users", 100, TimeSpan.FromHours(1)); // Update without TTL - REMOVES the existing TTL await cache.SetIfHigherAsync("max-users", 150, null); // No TTL now! // Update with TTL - sets new TTL await cache.SetIfHigherAsync("max-users", 200, TimeSpan.FromHours(2)); // TTL = 2 hours // Zero/negative removes key, returns 0 var diff = await cache.SetIfHigherAsync("max-users", 999, TimeSpan.Zero); // 0 ``` ### Managing Expiration ```csharp // Check remaining TTL TimeSpan? ttl = await cache.GetExpirationAsync("session"); if (ttl == null) { // Key doesn't exist OR has no expiration } // Update expiration on existing key await cache.SetExpirationAsync("session", TimeSpan.FromMinutes(30)); // Remove expiration (make permanent) - use SetAllExpirationAsync with null await cache.SetAllExpirationAsync(new Dictionary { ["session"] = null // Removes TTL, key becomes permanent }); // Bulk get/set expirations var ttls = await cache.GetAllExpirationAsync(new[] { "key1", "key2", "key3" }); await cache.SetAllExpirationAsync(new Dictionary { ["key1"] = TimeSpan.FromMinutes(10), ["key2"] = TimeSpan.FromHours(1), ["key3"] = null // Remove expiration }); ``` ::: Warning Azure Managed Redis On Azure Managed Redis (and many Redis deployments), the default eviction policy is `volatile-lru`, meaning **only keys with a TTL are eligible for eviction**. If you create many non-expiring keys, you may experience memory pressure and write failures. **Recommendations:** * Always set appropriate TTLs for cache entries when possible * Use `TimeSpan.MaxValue` only when you explicitly need permanent storage * Monitor your Redis memory usage and eviction metrics **Further Reading:** * [Azure Managed Cache for Redis eviction policies](https://docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-configure#memory-policies) * [Redis eviction policies documentation](https://redis.io/docs/reference/eviction/) ::: ## Implementations ### InMemoryCacheClient An in-memory cache implementation (L1 cache) valid for the lifetime of the process. See the [In-Memory Implementation Guide](./implementations/in-memory) for detailed configuration options including memory-based eviction. [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Caching/InMemoryCacheClient.cs) ```csharp using Foundatio.Caching; var cache = new InMemoryCacheClient(); // Basic operations await cache.SetAsync("key", "value"); var result = await cache.GetAsync("key"); // With expiration await cache.SetAsync("session", sessionData, TimeSpan.FromMinutes(30)); // With item limits (LRU eviction) var limitedCache = new InMemoryCacheClient(o => o.MaxItems = 1000); ``` ### HybridCacheClient Combines a local in-memory cache (L1) with a distributed cache (L2) for maximum performance. This implements the industry-standard L1/L2 caching architecture, ideal for read-heavy workloads where the same data is accessed frequently across multiple requests. [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Caching/HybridCacheClient.cs) ```csharp using Foundatio.Caching; var hybridCache = new HybridCacheClient( redisCacheClient, redisMessageBus, new InMemoryCacheClientOptions { MaxItems = 1000 } ); // First access: fetches from Redis, caches locally var user = await hybridCache.GetAsync("user:123"); // Subsequent access: returns from local cache (no network call) var sameUser = await hybridCache.GetAsync("user:123"); ``` **Key features:** * **Read-through**: L1 (local) cache miss falls back to L2 (distributed) cache * **Write-through**: Writes go to L2 first, then L1 only on success (distributed-first pattern) * **Key-specific invalidation**: Only affected keys are cleared on other instances * **Message bus coordination**: Automatic invalidation across all hybrid cache instances ::: tip Full Documentation See [Hybrid Cache Implementation](/guide/implementations/hybrid-cache) for detailed configuration, performance considerations, and best practices. ::: ### ScopedCacheClient Prefix all cache keys for easy namespacing: [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Caching/ScopedCacheClient.cs) ```csharp using Foundatio.Caching; var cache = new InMemoryCacheClient(); var tenantCache = new ScopedCacheClient(cache, "tenant:abc"); // All keys automatically prefixed await tenantCache.SetAsync("settings", settings); // Key: "tenant:abc:settings" await tenantCache.SetAsync("users", users); // Key: "tenant:abc:users" // Clear all keys for this tenant await tenantCache.RemoveByPrefixAsync(""); // Removes tenant:abc:* ``` **Use cases:** * Multi-tenant applications * Feature-specific caches * Test isolation ### RedisCacheClient Distributed cache using Redis (separate package): [View source](https://github.com/FoundatioFx/Foundatio.Redis/blob/main/src/Foundatio.Redis/Cache/RedisCacheClient.cs) ```csharp // dotnet add package Foundatio.Redis using Foundatio.Redis.Cache; using StackExchange.Redis; var redis = await ConnectionMultiplexer.ConnectAsync("localhost:6379"); var cache = new RedisCacheClient(o => o.ConnectionMultiplexer = redis); await cache.SetAsync("user:123", user, TimeSpan.FromHours(1)); ``` ### RedisHybridCacheClient Combines `RedisCacheClient` with `HybridCacheClient`: [View source](https://github.com/FoundatioFx/Foundatio.Redis/blob/main/src/Foundatio.Redis/Cache/RedisHybridCacheClient.cs) ```csharp var redis = await ConnectionMultiplexer.ConnectAsync("localhost:6379"); var hybridCache = new RedisHybridCacheClient( redisConfig => redisConfig.ConnectionMultiplexer(redis), localConfig => localConfig.MaxItems(1000) ); ``` ## Cache Interface Hierarchy Foundatio provides several cache interfaces for different use cases: ``` ICacheClient (base interface) ├── IMemoryCacheClient (in-memory specific) ├── IHybridCacheClient (local + distributed) └── IHybridAwareCacheClient (distributed with invalidation) ``` ### IHybridCacheClient Implemented by `HybridCacheClient` ([view source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Caching/HybridCacheClient.cs)). Combines a local in-memory cache (L1) with a distributed cache (L2). When you write data, it: 1. Writes to L2 (distributed cache) first - the source of truth 2. Updates L1 (local cache) only if L2 succeeds 3. Publishes an invalidation message via `IMessageBus` When you read data: 1. Checks L1 (local cache) first (fast, no network) 2. Falls back to L2 (distributed cache) on miss 3. Populates L1 with result ### IHybridAwareCacheClient Implemented by `HybridAwareCacheClient` ([view source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Caching/HybridAwareCacheClient.cs)). Wraps a distributed cache (L2) and publishes invalidation messages **without maintaining a local cache (L1)**. Use this when: * You have a service that only writes to cache (e.g., background processor) * You want to notify `HybridCacheClient` instances to invalidate their L1 caches * You don't need local caching on this particular service ```csharp // Service that writes data but doesn't need local caching var cacheWriter = new HybridAwareCacheClient( distributedCacheClient: redisCacheClient, messagePublisher: redisMessageBus ); // Write goes to Redis AND notifies all HybridCacheClient instances await cacheWriter.SetAsync("user:123", user); // Other services using HybridCacheClient will clear their local "user:123" cache ``` ### IMemoryCacheClient A marker interface that identifies in-memory cache implementations (e.g., `InMemoryCacheClient`). This interface is used for type checking and dependency injection scenarios where you need to distinguish between L1 (in-memory) and L2 (distributed) cache implementations. ```csharp // Register specific implementation type services.AddSingleton(); // Inject when you specifically need in-memory behavior public class MyService(IMemoryCacheClient localCache) { } ``` ## Performance Considerations ### Serialization and Cloning Overhead Every cache operation has performance overhead from serialization, deserialization, and optional value cloning: **Distributed Cache (Redis, Azure, etc.):** * **Write**: Serialize object to bytes for storage * **Read**: Deserialize bytes back to object **In-Memory Cache:** * **With `CloneValues = true`** (default: `false`): Serialize and deserialize on every get/set to create independent copies * **With `CloneValues = false`**: Direct reference storage (no overhead, but risk of mutation) ### Value Cloning {#clonevalues} The `CloneValues` option controls whether cached values are cloned on read and write operations. This is critical for preventing reference sharing bugs. **Default: `false`** (no cloning, direct reference storage) #### The Problem: Reference Sharing Without cloning, the cache stores direct references to objects. If code mutates a cached object, **all future reads see the mutated value**: ```csharp var cache = new InMemoryCacheClient(); // CloneValues = false (default) var user = new User { Name = "Alice", Balance = 100.0 }; await cache.SetAsync("user:1", user); // Get from cache and accidentally mutate var cached = (await cache.GetAsync("user:1")).Value; cached.Balance = 0.0; // ⚠️ Mutates the cached object! // Later reads return the MUTATED value var again = (await cache.GetAsync("user:1")).Value; Console.WriteLine(again.Balance); // 0.0 (not 100.0!) ``` This is especially dangerous when: * Multiple code paths access the same cached data * Objects are passed through layers (controllers → services → repositories) * Async code shares cached instances across concurrent requests #### The Solution: Enable Cloning ```csharp var cache = new InMemoryCacheClient(o => o.CloneValues = true); var user = new User { Name = "Alice", Balance = 100.0 }; await cache.SetAsync("user:1", user); // Mutations are isolated to this copy var cached = (await cache.GetAsync("user:1")).Value; cached.Balance = 0.0; // Only affects this instance // Fresh reads get original value var fresh = (await cache.GetAsync("user:1")).Value; Console.WriteLine(fresh.Balance); // 100.0 ✓ ``` **How it works:** When `CloneValues = true`, each `GetAsync` and `SetAsync` serializes and deserializes the value using the configured serializer (default: JSON). This creates independent copies that are isolated from mutation. #### Performance Trade-offs | Operation | `CloneValues = false` | `CloneValues = true` | |-----------|----------------------|---------------------| | `SetAsync` | Store reference (zero overhead) | Serialize → Deserialize (overhead) | | `GetAsync` | Return reference (zero overhead) | Serialize → Deserialize (overhead) | | Memory | Single instance | Multiple copies | **Benchmark example (10,000 operations):** * Simple objects (< 1KB): ~2-5ms overhead per 10k ops * Complex objects (> 10KB): ~50-100ms overhead per 10k ops * Primitive types: Negligible difference #### When to Enable Cloning ✅ **Enable `CloneValues = true` when:** * Caching mutable objects (DTOs, entities, view models) * Multiple code paths access the same cached data * You can't guarantee code won't mutate cached objects * Working with shared state across async operations * Debugging unexplained cache corruption ❌ **Keep `CloneValues = false` when:** * Caching immutable types (strings, primitives, records with `init`-only properties) * You have strict control over mutation (internal APIs, single code path) * Performance is critical and you can guarantee immutability * Using frozen/immutable collections (`ImmutableArray`, `FrozenDictionary`) #### Best Practices **Pattern 1: Use immutable types (no cloning needed):** ```csharp var cache = new InMemoryCacheClient(); // CloneValues = false // C# records are immutable by default public record UserDto(int Id, string Name, decimal Balance); await cache.SetAsync("user:1", new UserDto(1, "Alice", 100.0)); // Safe: Cannot mutate records ``` **Pattern 2: Clone when mutability is unavoidable:** ```csharp var cache = new InMemoryCacheClient(o => o.CloneValues = true); // Mutable class public class UserEntity { public int Id { get; set; } public string Name { get; set; } public decimal Balance { get; set; } } await cache.SetAsync("user:1", userEntity); // Safe: Mutations are isolated ``` **Pattern 3: Mix both strategies:** ```csharp // Separate caches for different needs var immutableCache = new InMemoryCacheClient(o => o.CloneValues = false); var mutableCache = new InMemoryCacheClient(o => o.CloneValues = true); // Immutable config await immutableCache.SetAsync("config:theme", "dark"); // Mutable user data await mutableCache.SetAsync("user:1", userEntity); ``` ### Hybrid Cache Performance For `HybridCacheClient`-specific performance considerations including message bus traffic, memory pressure, and optimization strategies, see [Hybrid Cache - Performance Considerations](/guide/implementations/hybrid-cache#performance-considerations). ## Common Patterns ### Cache-Aside Pattern The most common caching pattern: ```csharp public async Task GetUserAsync(int userId) { var cacheKey = $"user:{userId}"; // Try cache first var cached = await _cache.GetAsync(cacheKey); if (cached.HasValue) return cached.Value; // Load from database var user = await _database.GetUserAsync(userId); // Cache for future requests await _cache.SetAsync(cacheKey, user, TimeSpan.FromMinutes(30)); return user; } ``` ### Cache Stampede Protection The cache-aside pattern above is vulnerable to **cache stampedes** (also called the thundering herd problem). When a popular key expires, many concurrent requests all see a cache miss simultaneously and each independently loads the same data from the backing store. For an expensive query that takes two seconds to run, 50 concurrent callers means 50 identical database queries instead of one. Use [`CacheLockProvider`](/guide/locks) to serialize cache regeneration so only one caller loads the data while others wait: ```csharp public class ProductService { private readonly ICacheClient _cache; private readonly ILockProvider _locker; public ProductService(ICacheClient cache, ILockProvider locker) { _cache = cache; _locker = locker; } public async Task GetProductAsync(int productId, CancellationToken ct) { var cacheKey = $"product:{productId}"; var cached = await _cache.GetAsync(cacheKey); if (cached.HasValue) return cached.Value; // Only one caller regenerates; others wait for the lock then re-check cache. await using var lck = await _locker.AcquireAsync( $"cache-load:{cacheKey}", timeUntilExpires: TimeSpan.FromSeconds(30), cancellationToken: ct); if (lck is null) return null; // Could not acquire -- caller decides how to handle // Double-check: another caller may have populated cache while we waited. cached = await _cache.GetAsync(cacheKey); if (cached.HasValue) return cached.Value; var product = await _database.GetProductAsync(productId); await _cache.SetAsync(cacheKey, product, TimeSpan.FromMinutes(30)); return product; } } ``` The key points of this pattern: 1. **Lock on the cache key.** Use a lock name derived from the cache key (e.g., `cache-load:product:42`) so different keys are loaded concurrently while the same key is serialized. 2. **Double-check after acquiring.** Another caller may have populated the cache while you were waiting for the lock. Always re-read before loading from the backing store. 3. **Lock expiration as a safety net.** Set `timeUntilExpires` to a value longer than the expected load time. If the loader crashes, the lock auto-expires and the next caller retries. ::: tip CacheLockProvider + IMessageBus When `CacheLockProvider` is configured with an `IMessageBus`, waiting callers are notified instantly via pub/sub when the lock is released. Without a message bus, lock release falls back to polling. For stampede protection where multiple callers are blocked on the same lock, the message bus significantly reduces wait time. ::: ### Atomic Operations Use conditional operations for race-safe updates: ```csharp // Only set if key doesn't exist bool added = await cache.AddAsync("lock:resource", "owner-id"); // Replace only if value matches expected bool replaced = await cache.ReplaceIfEqualAsync("counter", 2, 1); // Atomic increment long newValue = await cache.IncrementAsync("page-views", 1); ``` ### Counter Patterns Track metrics with atomic operations: ```csharp // Increment counters await cache.IncrementAsync("api:calls:today", 1); await cache.IncrementAsync("user:123:login-count", 1); // Track high-water marks await cache.SetIfHigherAsync("max-concurrent-users", currentUsers); // Track minimums await cache.SetIfLowerAsync("fastest-response-ms", responseTime); ``` #### SetIfHigher/SetIfLower Return Values These methods return the **difference** between the new and old values, not the new value itself: ```csharp // Key doesn't exist - returns the value itself (difference from 0) double diff = await cache.SetIfHigherAsync("max-users", 100); // Returns 100 // Value is higher - returns the delta diff = await cache.SetIfHigherAsync("max-users", 150); // Returns 50 (150 - 100) // Value is NOT higher - returns 0 (no change) diff = await cache.SetIfHigherAsync("max-users", 120); // Returns 0 // To get the actual current value after the operation: var currentMax = (await cache.GetAsync("max-users")).Value; // 150 ``` ::: warning Conditional Expiration Behavior `SetIfHigherAsync` and `SetIfLowerAsync` only update the expiration **when the condition is met**. If the value is not higher/lower, the operation is a complete no-op—including the expiration. ```csharp // Set with 1-hour TTL await cache.SetIfHigherAsync("max-users", 100, TimeSpan.FromHours(1)); // Try to set lower value with 2-hour TTL await cache.SetIfHigherAsync("max-users", 50, TimeSpan.FromHours(2)); // TTL is STILL 1 hour! The condition failed, so nothing changed. // Set higher value with 2-hour TTL await cache.SetIfHigherAsync("max-users", 200, TimeSpan.FromHours(2)); // TTL is now 2 hours (condition was met) ``` This is intentional—the semantic is "set IF higher/lower", so a failed condition means the entire operation is skipped. ::: ### List Operations Foundatio lists support **per-value expiration**, where each item in the list can have its own independent TTL. This is different from standard cache keys where expiration applies to the entire key. #### Why Per-Value Expiration? Per-value expiration prevents unbounded list growth. Consider tracking recently deleted items: ```csharp // Without per-value expiration (sliding expiration problem): // Adding ANY item resets the entire list's TTL, causing indefinite growth await cache.ListAddAsync("deleted-items", [itemId], TimeSpan.FromDays(7)); // After months: list has 100,000+ items because TTL keeps resetting! // With per-value expiration (Foundatio's approach): // Each item expires independently after 7 days await cache.ListAddAsync("deleted-items", [itemId], TimeSpan.FromDays(7)); // List stays bounded - old items expire even as new ones are added ``` **Real-world use cases:** * **Soft-delete tracking**: Track deleted document IDs that should be filtered from queries * **Recent activity feeds**: Each activity expires independently (e.g., "active in last 5 minutes") * **Rate limiting windows**: Track individual requests with their own expiration * **Session tracking**: Track user sessions where each session has its own timeout #### Basic List Usage ```csharp // Add items with per-value expiration (each item expires in 1 hour) await cache.ListAddAsync("user:123:recent-searches", new[] { "query1" }, TimeSpan.FromHours(1)); await cache.ListAddAsync("user:123:recent-searches", new[] { "query2" }, TimeSpan.FromHours(1)); // Items expire independently - query1 expires 1 hour after it was added, // query2 expires 1 hour after IT was added (not when query1 was added) // Get paginated list (expired items are automatically filtered) var searches = await cache.GetListAsync( "user:123:recent-searches", page: 0, pageSize: 10 ); // Remove specific items from list await cache.ListRemoveAsync("user:123:recent-searches", new[] { "query1" }); ``` #### List Expiration Behavior | `expiresIn` Value | Behavior | |-------------------|----------| | `null` | Values will not expire. Key expiration is set to max of all item expirations. | | Positive `TimeSpan` | Each value expires independently after this duration. | | Zero or negative | The specified values are removed from the list (if present), returns 0. | ### Bulk Operations Efficiently work with multiple keys: ```csharp // Get multiple values var keys = new[] { "user:1", "user:2", "user:3" }; var users = await cache.GetAllAsync(keys); // Set multiple values var values = new Dictionary { ["user:1"] = user1, ["user:2"] = user2, }; await cache.SetAllAsync(values, TimeSpan.FromHours(1)); // Remove multiple keys await cache.RemoveAllAsync(keys); ``` ## Dependency Injection ### Basic Registration ```csharp // In-memory (development) services.AddSingleton(); // With options services.AddSingleton(sp => new InMemoryCacheClient(o => o.MaxItems = 1000)); // Redis (production) services.AddSingleton(sp => new RedisCacheClient(o => o.ConnectionMultiplexer = redis)); ``` ### Hybrid with DI ```csharp services.AddSingleton(sp => { var redis = sp.GetRequiredService(); return new HybridCacheClient( new RedisCacheClient(o => o.ConnectionMultiplexer = redis), sp.GetRequiredService() ); }); ``` ### Named Caches Use different caches for different purposes: ```csharp services.AddKeyedSingleton("session", new InMemoryCacheClient(o => o.MaxItems = 10000)); services.AddKeyedSingleton("geo", new InMemoryCacheClient(o => o.MaxItems = 250)); ``` ## Best Practices ### 1. Use Meaningful Key Patterns ```csharp // ✅ Good: Clear, hierarchical, identifiable "user:123:profile" "tenant:abc:settings" "api:rate-limit:192.168.1.1" // ❌ Bad: Ambiguous, no structure "data" "123" "cache_item" ``` ### 2. Set Appropriate Expiration ```csharp // Session data - short expiration await cache.SetAsync("session:xyz", data, TimeSpan.FromMinutes(30)); // Reference data - longer expiration await cache.SetAsync("config:app", config, TimeSpan.FromHours(24)); // Computed data - based on freshness needs await cache.SetAsync("report:daily", report, TimeSpan.FromHours(1)); ``` ### 3. Handle Cache Misses Gracefully ```csharp var cached = await cache.GetAsync("user:123"); if (!cached.HasValue) { // Handle miss - load from source return await LoadFromDatabaseAsync(123); } // cached.Value is the User, cached.IsNull is true if explicitly cached as null ``` ### 4. Use Scoped Caches for Isolation ```csharp // Per-tenant isolation var tenantCache = new ScopedCacheClient(cache, $"tenant:{tenantId}"); // Per-feature isolation var featureCache = new ScopedCacheClient(cache, "feature:recommendations"); ``` ### 5. Consider Hybrid for High-Read Scenarios If you're doing many reads of the same data across instances, `HybridCacheClient` can dramatically reduce latency and Redis load. ## Next Steps * [Queues](./queues) - Message queuing for background processing * [Locks](./locks) - Distributed locking with cache-based implementation * [Redis Implementation](./implementations/redis) - Production Redis setup * [Serialization](./serialization) - Serializer configuration and performance --- --- url: /guide/configuration.md --- # Configuration This guide covers configuration options for various Foundatio components. ## Cache Configuration ### InMemoryCacheClient ```csharp var cache = new InMemoryCacheClient(options => { // Maximum number of items (LRU eviction) options.MaxItems = 1000; // Clone values on get/set (default: true) options.CloneValues = true; // Expiration scan frequency options.ExpirationScanFrequency = TimeSpan.FromMinutes(1); // Logger options.LoggerFactory = loggerFactory; // Time provider for testing options.TimeProvider = timeProvider; }); ``` ### HybridCacheClient ```csharp var hybridCache = new HybridCacheClient( redisCacheClient, redisMessageBus, new InMemoryCacheClientOptions { MaxItems = 500, LoggerFactory = loggerFactory } ); ``` ### ScopedCacheClient ```csharp var scopedCache = new ScopedCacheClient( cache: baseCacheClient, scope: "tenant:123" ); ``` ## Queue Configuration ### InMemoryQueue ```csharp var queue = new InMemoryQueue(options => { // Queue name/identifier options.Name = "work-items"; // Work item timeout options.WorkItemTimeout = TimeSpan.FromMinutes(5); // Retry settings options.Retries = 3; options.RetryDelay = TimeSpan.FromSeconds(30); // Logger options.LoggerFactory = loggerFactory; // Serializer options.Serializer = serializer; }); ``` ### RedisQueue ```csharp var queue = new RedisQueue(options => { // Redis connection options.ConnectionMultiplexer = redis; // Queue name options.Name = "work-items"; // Work item timeout options.WorkItemTimeout = TimeSpan.FromMinutes(5); // Dead letter settings options.DeadLetterTimeToLive = TimeSpan.FromDays(1); options.DeadLetterMaxItems = 100; // Retry settings options.Retries = 3; options.RetryDelay = TimeSpan.FromSeconds(30); // Logger options.LoggerFactory = loggerFactory; }); ``` ## Messaging Configuration ### InMemoryMessageBus ```csharp var messageBus = new InMemoryMessageBus(options => { // Logger options.LoggerFactory = loggerFactory; // Serializer options.Serializer = serializer; }); ``` ### RedisMessageBus ```csharp var messageBus = new RedisMessageBus(options => { // Redis subscriber options.Subscriber = redis.GetSubscriber(); // Topic prefix options.Topic = "myapp"; // Logger options.LoggerFactory = loggerFactory; // Serializer options.Serializer = serializer; }); ``` ## Lock Configuration ### CacheLockProvider ```csharp var locker = new CacheLockProvider( cache: cacheClient, messageBus: messageBus, options => { // Default lock expiration options.DefaultTimeToLive = TimeSpan.FromMinutes(5); // Logger options.LoggerFactory = loggerFactory; } ); ``` ### ThrottlingLockProvider ```csharp var throttler = new ThrottlingLockProvider( cache: cacheClient, maxHits: 100, // Maximum operations period: TimeSpan.FromMinutes(1) // Per time period ); ``` ## Storage Configuration ### FolderFileStorage ```csharp var storage = new FolderFileStorage(options => { // Root folder path options.Folder = "/data/files"; // Logger options.LoggerFactory = loggerFactory; // Serializer options.Serializer = serializer; }); ``` ### InMemoryFileStorage ```csharp var storage = new InMemoryFileStorage(options => { // Maximum number of files options.MaxFiles = 1000; // Maximum total size options.MaxFileSize = 100 * 1024 * 1024; // 100MB // Logger options.LoggerFactory = loggerFactory; // Serializer options.Serializer = serializer; }); ``` ### AzureFileStorage ```csharp var storage = new AzureFileStorage(options => { // Connection string options.ConnectionString = "DefaultEndpointsProtocol=https;..."; // Container name options.ContainerName = "files"; // Logger options.LoggerFactory = loggerFactory; // Serializer options.Serializer = serializer; }); ``` ### S3FileStorage ```csharp var storage = new S3FileStorage(options => { // AWS region options.Region = RegionEndpoint.USEast1; // Bucket name options.Bucket = "my-files"; // Optional credentials (uses default chain if not specified) options.AccessKey = "..."; options.SecretKey = "..."; // Logger options.LoggerFactory = loggerFactory; }); ``` ## Resilience Configuration ### ResiliencePolicy ```csharp var policy = new ResiliencePolicy { // Maximum attempts MaxAttempts = 5, // Fixed delay between retries Delay = TimeSpan.FromSeconds(2), // Or exponential delay GetDelay = ResiliencePolicy.ExponentialDelay(TimeSpan.FromSeconds(1)), // Maximum delay cap MaxDelay = TimeSpan.FromMinutes(1), // Add jitter UseJitter = true, // Overall timeout Timeout = TimeSpan.FromMinutes(5), // Exceptions to not retry UnhandledExceptions = { typeof(OperationCanceledException) }, // Custom retry logic ShouldRetry = (attempt, ex) => ex is TransientException, // Logger Logger = logger }; ``` ### CircuitBreaker ```csharp var circuitBreaker = new CircuitBreakerBuilder() // Failure threshold (0.0 - 1.0) .WithFailureRatio(0.5) // Minimum calls before evaluating .WithMinimumCalls(10) // Duration to keep circuit open .WithBreakDuration(TimeSpan.FromMinutes(1)) // Success threshold for half-open recovery .WithSuccessThreshold(3) // Sampling duration for failure rate .WithSamplingDuration(TimeSpan.FromMinutes(5)) .Build(); ``` ## Job Configuration ### JobOptions ```csharp var options = new JobOptions { // Job name for logging Name = "CleanupJob", // Interval between runs Interval = TimeSpan.FromHours(1), // Maximum iterations (-1 for unlimited) IterationLimit = -1, // Initial run delay InitialDelay = TimeSpan.FromMinutes(5) }; await job.RunContinuousAsync(options, stoppingToken); ``` ### JobRunner ```csharp var runner = new JobRunner( job: myJob, instanceCount: 4, // Number of parallel instances interval: TimeSpan.FromSeconds(5) ); ``` ## Serialization Configuration ### Custom Serializer ```csharp // Using System.Text.Json var serializer = new SystemTextJsonSerializer(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }); // Apply to services var cache = new InMemoryCacheClient(o => o.Serializer = serializer); var queue = new InMemoryQueue(o => o.Serializer = serializer); var storage = new InMemoryFileStorage(o => o.Serializer = serializer); ``` ## Logging Configuration ### Configure Logging ```csharp var loggerFactory = LoggerFactory.Create(builder => { builder .AddConsole() .SetMinimumLevel(LogLevel.Information) .AddFilter("Foundatio", LogLevel.Debug); }); // Apply to services var cache = new InMemoryCacheClient(o => o.LoggerFactory = loggerFactory); var queue = new InMemoryQueue(o => o.LoggerFactory = loggerFactory); ``` ## Environment Variables ### Common Environment Variables ```bash # Redis connection FOUNDATIO_REDIS_CONNECTION=localhost:6379 # Azure Storage FOUNDATIO_AZURE_STORAGE_CONNECTION=DefaultEndpointsProtocol=https;... # AWS AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_REGION=us-east-1 ``` ### Reading from Configuration ```csharp var builder = WebApplication.CreateBuilder(args); var redisConnection = builder.Configuration["Foundatio:Redis:Connection"]; var azureStorage = builder.Configuration["Foundatio:Azure:StorageConnection"]; builder.Services.AddSingleton(sp => { if (!string.IsNullOrEmpty(redisConnection)) { var redis = ConnectionMultiplexer.Connect(redisConnection); return new RedisCacheClient(o => o.ConnectionMultiplexer = redis); } return new InMemoryCacheClient(); }); ``` ## appsettings.json Example ```json { "Foundatio": { "Cache": { "Type": "Redis", "Connection": "localhost:6379", "MaxItems": 1000 }, "Queue": { "Type": "Redis", "WorkItemTimeout": "00:05:00", "Retries": 3 }, "Storage": { "Type": "Azure", "ConnectionString": "...", "ContainerName": "files" }, "Resilience": { "MaxAttempts": 5, "InitialDelay": "00:00:01", "UseJitter": true } } } ``` ### Reading Configuration ```csharp public class FoundatioOptions { public CacheOptions Cache { get; set; } public QueueOptions Queue { get; set; } public StorageOptions Storage { get; set; } public ResilienceOptions Resilience { get; set; } } public class CacheOptions { public string Type { get; set; } public string Connection { get; set; } public int MaxItems { get; set; } } // Registration builder.Services.Configure( builder.Configuration.GetSection("Foundatio")); ``` ## Best Practices ### 1. Use Options Pattern ```csharp // Define options class public class CacheOptions { public string Type { get; set; } = "InMemory"; public int MaxItems { get; set; } = 1000; public string RedisConnection { get; set; } } // Register and use builder.Services.Configure( builder.Configuration.GetSection("Cache")); builder.Services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; return options.Type == "Redis" ? new RedisCacheClient(...) : new InMemoryCacheClient(o => o.MaxItems = options.MaxItems); }); ``` ### 2. Validate Configuration ```csharp builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Cache")) .ValidateDataAnnotations() .ValidateOnStart(); ``` ### 3. Use Environment-Specific Settings ```bash appsettings.json # Base settings appsettings.Development.json # In-memory implementations appsettings.Production.json # Redis/Azure implementations ``` ### 4. Keep Secrets Secure ```csharp // Use secrets manager var redis = builder.Configuration["Redis:ConnectionString"]; // Or environment variables var redis = Environment.GetEnvironmentVariable("REDIS_CONNECTION"); ``` ## Next Steps * [Dependency Injection](./dependency-injection) - Service registration patterns * [Getting Started](./getting-started) - Initial setup guide * [Caching](./caching) - Cache configuration details --- --- url: /guide/dependency-injection.md --- # Dependency Injection Foundatio is designed to work seamlessly with Microsoft.Extensions.DependencyInjection. All abstractions are interface-based and can be easily registered and resolved. ## Basic Registration ### Manual Registration ```csharp using Foundatio.Caching; using Foundatio.Messaging; using Foundatio.Lock; using Foundatio.Storage; using Foundatio.Queues; var builder = WebApplication.CreateBuilder(args); // Core services builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Lock provider (depends on cache and message bus) builder.Services.AddSingleton(sp => new CacheLockProvider( sp.GetRequiredService(), sp.GetRequiredService() ) ); // Queues builder.Services.AddSingleton>(sp => new InMemoryQueue() ); ``` ### Using Extension Methods ```csharp using Foundatio; builder.Services.AddFoundatio(); // Adds default in-memory implementations ``` ## Service Lifetimes ### Recommended Lifetimes | Service | Lifetime | Reason | |---------|----------|--------| | `ICacheClient` | Singleton | Maintains internal state/connection | | `IMessageBus` | Singleton | Maintains subscriptions | | `ILockProvider` | Singleton | Stateless, thread-safe | | `IFileStorage` | Singleton | Stateless, thread-safe | | `IQueue` | Singleton | Maintains queue state | | Jobs | Scoped | Per-execution isolation | ### Example Registration ```csharp // Singletons for infrastructure builder.Services.AddSingleton(sp => new InMemoryCacheClient(o => o.MaxItems = 1000)); builder.Services.AddSingleton(); // Scoped for per-request isolation builder.Services.AddScoped(sp => new ScopedLockProvider( sp.GetRequiredService(), $"tenant:{GetCurrentTenantId(sp)}" ) ); ``` ## Environment-Based Configuration ### Development vs Production ```csharp if (builder.Environment.IsDevelopment()) { // In-memory for development builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); } else { // Redis for production var redis = ConnectionMultiplexer.Connect( builder.Configuration.GetConnectionString("Redis") ); builder.Services.AddSingleton(redis); builder.Services.AddSingleton(sp => new RedisCacheClient(o => o.ConnectionMultiplexer = redis)); builder.Services.AddSingleton(sp => new RedisMessageBus(o => o.Subscriber = redis.GetSubscriber())); builder.Services.AddSingleton(sp => new AzureFileStorage(o => { o.ConnectionString = builder.Configuration["Azure:StorageConnectionString"]; o.ContainerName = "files"; })); } ``` ### Using Options Pattern ```csharp // appsettings.json { "Foundatio": { "Cache": { "Type": "Redis", "MaxItems": 1000 }, "Storage": { "Type": "Azure", "ContainerName": "files" } } } // Registration builder.Services.Configure( builder.Configuration.GetSection("Foundatio")); builder.Services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; return options.Cache.Type switch { "Redis" => new RedisCacheClient(...), "InMemory" => new InMemoryCacheClient(o => o.MaxItems = options.Cache.MaxItems), _ => throw new InvalidOperationException() }; }); ``` ## Named/Keyed Services ### Multiple Implementations ```csharp // Multiple caches builder.Services.AddKeyedSingleton("session", sp => new InMemoryCacheClient(o => o.MaxItems = 10000)); builder.Services.AddKeyedSingleton("data", sp => new RedisCacheClient(o => o.ConnectionMultiplexer = redis)); // Multiple queues builder.Services.AddKeyedSingleton>("high-priority", sp => new InMemoryQueue()); builder.Services.AddKeyedSingleton>("low-priority", sp => new InMemoryQueue()); ``` ### Injecting Keyed Services ```csharp public class OrderService { private readonly ICacheClient _sessionCache; private readonly ICacheClient _dataCache; public OrderService( [FromKeyedServices("session")] ICacheClient sessionCache, [FromKeyedServices("data")] ICacheClient dataCache) { _sessionCache = sessionCache; _dataCache = dataCache; } } ``` ## Factory Pattern ### Dynamic Resolution ```csharp public interface ICacheClientFactory { ICacheClient GetCache(string name); } public class CacheClientFactory : ICacheClientFactory { private readonly IServiceProvider _services; private readonly ConcurrentDictionary _caches = new(); public CacheClientFactory(IServiceProvider services) { _services = services; } public ICacheClient GetCache(string name) { return _caches.GetOrAdd(name, n => { var baseCache = _services.GetRequiredService(); return new ScopedCacheClient(baseCache, n); }); } } // Registration builder.Services.AddSingleton(); builder.Services.AddSingleton(); ``` ## Multi-Tenant Support ### Tenant-Scoped Services ```csharp public interface ITenantAccessor { string TenantId { get; } } // Scoped cache per tenant builder.Services.AddScoped(sp => { var baseCache = sp.GetRequiredService(); var tenant = sp.GetRequiredService(); return new ScopedCacheClient(baseCache, $"tenant:{tenant.TenantId}"); }); // Scoped storage per tenant builder.Services.AddScoped(sp => { var baseStorage = sp.GetRequiredService(); var tenant = sp.GetRequiredService(); return new ScopedFileStorage(baseStorage, tenant.TenantId); }); // Scoped locks per tenant builder.Services.AddScoped(sp => { var baseLock = sp.GetRequiredService(); var tenant = sp.GetRequiredService(); return new ScopedLockProvider(baseLock, tenant.TenantId); }); ``` ## Health Checks ### Register Health Checks ```csharp builder.Services.AddHealthChecks() .AddCheck("cache") .AddCheck("storage") .AddCheck("queue"); public class CacheHealthCheck : IHealthCheck { private readonly ICacheClient _cache; public CacheHealthCheck(ICacheClient cache) => _cache = cache; public async Task CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { try { await _cache.SetAsync("health-check", DateTime.UtcNow); var result = await _cache.GetAsync("health-check"); return result.HasValue ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy("Cache read failed"); } catch (Exception ex) { return HealthCheckResult.Unhealthy(ex.Message); } } } ``` ## Testing ### Test-Friendly Registration ```csharp // In test setup public class TestStartup { public void ConfigureServices(IServiceCollection services) { // Always use in-memory for tests services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => new CacheLockProvider( sp.GetRequiredService(), sp.GetRequiredService() ) ); } } ``` ### Isolated Tests ```csharp public class OrderServiceTests { private readonly ServiceProvider _services; public OrderServiceTests() { var services = new ServiceCollection(); // Fresh instances for each test class services.AddSingleton(); services.AddSingleton(); _services = services.BuildServiceProvider(); } [Fact] public async Task CreateOrder_CachesOrder() { var cache = _services.GetRequiredService(); var service = new OrderService(cache); var order = await service.CreateOrderAsync(new CreateOrderRequest()); var cached = await cache.GetAsync($"order:{order.Id}"); Assert.True(cached.HasValue); } } ``` ## Best Practices ### 1. Proper Resource Disposal Foundatio services implement `IDisposable` and/or `IAsyncDisposable`. The DI container handles disposal for registered services, but you must handle disposal correctly for manually created instances. ```csharp // ✅ Good: DI container handles disposal builder.Services.AddSingleton(); // Container disposes when application shuts down // ✅ Good: Using statement for short-lived instances await using var cache = new InMemoryCacheClient(); await cache.SetAsync("key", "value"); // Automatically disposed // ✅ Good: Manual disposal when needed var queue = new InMemoryQueue(); try { await queue.EnqueueAsync(new WorkItem()); } finally { queue.Dispose(); // Or await using for IAsyncDisposable } // ❌ Bad: Not disposing manually created instances var cache = new InMemoryCacheClient(); // ... use cache // Never disposed - resources leak! ``` ### 2. Async Disposal with `await using` For services implementing `IAsyncDisposable`, prefer `await using`: ```csharp // Locks implement IAsyncDisposable await using var lck = await locker.AcquireAsync("resource"); if (lck is null) throw new InvalidOperationException("Failed to acquire lock on 'resource'"); await DoWork(); // Lock automatically released // Queue entries should be completed/abandoned var entry = await queue.DequeueAsync(); if (entry is null) return; try { await ProcessAsync(entry.Value); await entry.CompleteAsync(); } catch { await entry.AbandonAsync(); throw; } ``` ### 3. Use Interfaces for Dependencies ```csharp // ✅ Good: Interface dependency public class OrderService { private readonly ICacheClient _cache; public OrderService(ICacheClient cache) { _cache = cache; } } // ❌ Bad: Concrete dependency public class OrderService { private readonly RedisCacheClient _cache; // Harder to test } ``` ### 4. Avoid Service Locator Pattern ```csharp // ✅ Good: Constructor injection public class MyService { private readonly ICacheClient _cache; public MyService(ICacheClient cache) { _cache = cache; } } // ❌ Bad: Service locator public class MyService { private readonly IServiceProvider _services; public void DoWork() { var cache = _services.GetService(); } } ``` ### 5. Register as Singletons When Appropriate ```csharp // Stateless services that maintain connections builder.Services.AddSingleton(...); builder.Services.AddSingleton(...); // Not scoped unless you need tenant isolation ``` ### 6. Validate Configuration at Startup ```csharp builder.Services.AddSingleton(sp => { var connectionString = builder.Configuration["Redis:ConnectionString"]; if (string.IsNullOrEmpty(connectionString)) throw new InvalidOperationException("Redis connection string not configured"); var redis = ConnectionMultiplexer.Connect(connectionString); return new RedisCacheClient(o => o.ConnectionMultiplexer = redis); }); ``` ## Next Steps * [Configuration](./configuration) - Configuration options for Foundatio services * [Caching](./caching) - Deep dive into caching * [Getting Started](./getting-started) - Initial setup guide --- --- url: /guide/storage.md --- # File Storage File storage provides abstracted file operations with multiple backend implementations. Foundatio's `IFileStorage` interface allows you to work with files consistently across local disk, cloud storage, and more. ## The IFileStorage Interface [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Storage/IFileStorage.cs) ```csharp public interface IFileStorage : IHaveSerializer, IDisposable { Task GetFileStreamAsync(string path, StreamMode streamMode, CancellationToken cancellationToken = default); Task GetFileInfoAsync(string path); Task ExistsAsync(string path); Task SaveFileAsync(string path, Stream stream, CancellationToken cancellationToken = default); Task RenameFileAsync(string path, string newPath, CancellationToken cancellationToken = default); Task CopyFileAsync(string path, string targetPath, CancellationToken cancellationToken = default); Task DeleteFileAsync(string path, CancellationToken cancellationToken = default); Task DeleteFilesAsync(string? searchPattern = null, CancellationToken cancellation = default); Task GetPagedFileListAsync(int pageSize = 100, string? searchPattern = null, CancellationToken cancellationToken = default); } ``` ## Implementations ### InMemoryFileStorage An in-memory storage for development and testing: [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Storage/InMemoryFileStorage.cs) ```csharp using Foundatio.Storage; var storage = new InMemoryFileStorage(); // Save a file await storage.SaveFileAsync("documents/report.pdf", pdfStream); // Read a file var stream = await storage.GetFileStreamAsync("documents/report.pdf", StreamMode.Read); ``` ### FolderFileStorage File storage backed by the local file system: [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Storage/FolderFileStorage.cs) ```csharp using Foundatio.Storage; var storage = new FolderFileStorage(o => o.Folder = "/data/files"); // Files are stored in /data/files/documents/report.pdf await storage.SaveFileAsync("documents/report.pdf", pdfStream); ``` ### ScopedFileStorage Prefix all paths with a scope: [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Storage/ScopedFileStorage.cs) ```csharp using Foundatio.Storage; var baseStorage = new FolderFileStorage(o => o.Folder = "/data"); var tenantStorage = new ScopedFileStorage(baseStorage, "tenant-abc"); // Path becomes: tenant-abc/documents/report.pdf await tenantStorage.SaveFileAsync("documents/report.pdf", pdfStream); ``` ### AzureFileStorage Azure Blob Storage (separate package): [View source](https://github.com/FoundatioFx/Foundatio.AzureStorage/blob/main/src/Foundatio.AzureStorage/Storage/AzureFileStorage.cs) ```csharp // dotnet add package Foundatio.AzureStorage using Foundatio.AzureStorage.Storage; var storage = new AzureFileStorage(o => { o.ConnectionString = "DefaultEndpointsProtocol=https;..."; o.ContainerName = "files"; }); ``` ### S3FileStorage AWS S3 Storage (separate package): [View source](https://github.com/FoundatioFx/Foundatio.AWS/blob/main/src/Foundatio.AWS/Storage/S3FileStorage.cs) ```csharp // dotnet add package Foundatio.AWS using Foundatio.AWS.Storage; var storage = new S3FileStorage(o => { o.Region = RegionEndpoint.USEast1; o.Bucket = "my-files"; }); ``` ### RedisFileStorage Redis-backed storage (separate package): [View source](https://github.com/FoundatioFx/Foundatio.Redis/blob/main/src/Foundatio.Redis/Storage/RedisFileStorage.cs) ```csharp // dotnet add package Foundatio.Redis using Foundatio.Redis.Storage; var storage = new RedisFileStorage(o => { o.ConnectionMultiplexer = redis; }); ``` ### MinioFileStorage Minio object storage (separate package): [View source](https://github.com/FoundatioFx/Foundatio.Minio/blob/main/src/Foundatio.Minio/Storage/MinioFileStorage.cs) ```csharp // dotnet add package Foundatio.Minio using Foundatio.Minio.Storage; var storage = new MinioFileStorage(o => { o.Endpoint = "localhost:9000"; o.AccessKey = "minioadmin"; o.SecretKey = "minioadmin"; o.Bucket = "files"; }); ``` ### SshNetFileStorage SFTP-backed storage (separate package): [View source](https://github.com/FoundatioFx/Foundatio.Storage.SshNet/blob/main/src/Foundatio.Storage.SshNet/SshNetFileStorage.cs) ```csharp // dotnet add package Foundatio.Storage.SshNet using Foundatio.Storage.SshNet; var storage = new SshNetFileStorage(o => { o.Host = "sftp.example.com"; o.Username = "user"; o.Password = "password"; o.WorkingDirectory = "/uploads"; }); ``` ## Basic Operations ### Saving Files ```csharp var storage = new InMemoryFileStorage(); // Save from stream using var stream = File.OpenRead("local-file.pdf"); await storage.SaveFileAsync("remote/file.pdf", stream); // Save string content (extension method) await storage.SaveFileAsync("config.json", """{"key": "value"}"""); // Save with object serialization (extension method) await storage.SaveObjectAsync("data/user.json", new User { Name = "John" }); ``` ### Reading Files ```csharp // Get file stream for reading using var stream = await storage.GetFileStreamAsync("file.pdf", StreamMode.Read); // Read as string (extension method) string? content = await storage.GetFileContentsAsync("config.json"); // Read and deserialize (extension method) var user = await storage.GetObjectAsync("data/user.json"); // Get raw bytes (extension method) byte[]? bytes = await storage.GetFileContentsRawAsync("image.png"); ``` ### File Information ```csharp // Check if file exists bool exists = await storage.ExistsAsync("file.pdf"); // Get file info var fileSpec = await storage.GetFileInfoAsync("file.pdf"); if (fileSpec != null) { Console.WriteLine($"Path: {fileSpec.Path}"); Console.WriteLine($"Size: {fileSpec.Size} bytes"); Console.WriteLine($"Modified: {fileSpec.Modified}"); Console.WriteLine($"Created: {fileSpec.Created}"); } ``` ### Modifying Files ```csharp // Rename/move file await storage.RenameFileAsync("old/path.pdf", "new/path.pdf"); // Copy file await storage.CopyFileAsync("source.pdf", "backup/source.pdf"); // Delete file await storage.DeleteFileAsync("file.pdf"); // Delete multiple files by pattern int deleted = await storage.DeleteFilesAsync("temp/*"); ``` ### Listing Files ```csharp // List all files var files = await storage.GetFileListAsync(); foreach (var file in files) { Console.WriteLine($"{file.Path} - {file.Size} bytes"); } // List with pattern var pdfFiles = await storage.GetFileListAsync("documents/*.pdf"); // Paged listing for large directories var result = await storage.GetPagedFileListAsync(pageSize: 100, "logs/*"); do { foreach (var file in result.Files) { Console.WriteLine(file.Path); } } while (await result.NextPageAsync()); ``` ## Stream Modes Control how file streams are opened: ```csharp // Read mode - for reading existing files using var readStream = await storage.GetFileStreamAsync("file.pdf", StreamMode.Read); // Write mode - for creating/overwriting files using var writeStream = await storage.GetFileStreamAsync("file.pdf", StreamMode.Write); await someData.CopyToAsync(writeStream); ``` ## Common Patterns ### File Upload/Download ```csharp public class FileService { private readonly IFileStorage _storage; public async Task UploadAsync(IFormFile file, string folder) { var path = $"{folder}/{Guid.NewGuid()}{Path.GetExtension(file.FileName)}"; using var stream = file.OpenReadStream(); await _storage.SaveFileAsync(path, stream); return path; } public async Task DownloadAsync(string path) { if (!await _storage.ExistsAsync(path)) throw new FileNotFoundException(path); return await _storage.GetFileStreamAsync(path, StreamMode.Read); } } ``` ### Organized File Structure ```csharp public class DocumentStorage { private readonly IFileStorage _storage; public string GetPath(int tenantId, int documentId, string fileName) { // Organized path: tenants/{id}/documents/{year}/{month}/{id}/{filename} var now = DateTime.UtcNow; return $"tenants/{tenantId}/documents/{now:yyyy}/{now:MM}/{documentId}/{fileName}"; } public async Task SaveDocumentAsync(int tenantId, int documentId, string fileName, Stream content) { var path = GetPath(tenantId, documentId, fileName); await _storage.SaveFileAsync(path, content); } } ``` ### File Versioning ```csharp public class VersionedFileStorage { private readonly IFileStorage _storage; public async Task SaveVersionAsync(string basePath, Stream content) { var version = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); var versionPath = $"{basePath}.v{version}"; // Save new version await _storage.SaveFileAsync(versionPath, content); // Update "current" pointer await _storage.CopyFileAsync(versionPath, basePath); } public async Task> GetVersionsAsync(string basePath) { return await _storage.GetFileListAsync($"{basePath}.v*"); } } ``` ### Temporary File Cleanup ```csharp public class TempFileCleanup { private readonly IFileStorage _storage; public async Task CleanupOldFilesAsync(TimeSpan maxAge) { var files = await _storage.GetFileListAsync("temp/*"); var cutoff = DateTime.UtcNow - maxAge; foreach (var file in files) { if (file.Modified < cutoff) { await _storage.DeleteFileAsync(file.Path); } } } } ``` ### Multi-Tenant Storage ```csharp public class TenantStorageFactory { private readonly IFileStorage _baseStorage; public IFileStorage GetStorageForTenant(string tenantId) { return new ScopedFileStorage(_baseStorage, $"tenants/{tenantId}"); } } // Usage var tenantStorage = _storageFactory.GetStorageForTenant("tenant-123"); await tenantStorage.SaveFileAsync("documents/report.pdf", stream); // Actual path: tenants/tenant-123/documents/report.pdf ``` ## Extension Methods Foundatio provides helpful extension methods: ```csharp // String content await storage.SaveFileAsync("text.txt", "Hello World"); string? text = await storage.GetFileContentsAsync("text.txt"); // Bytes await storage.SaveFileAsync("data.bin", byteArray); byte[]? bytes = await storage.GetFileContentsRawAsync("data.bin"); // Objects (serialized) await storage.SaveObjectAsync("user.json", user); var user = await storage.GetObjectAsync("user.json"); // Non-paged file listing var allFiles = await storage.GetFileListAsync(); var filtered = await storage.GetFileListAsync("*.pdf"); ``` ## Dependency Injection ### Basic Registration ```csharp // In-memory (development) services.AddSingleton(); // Folder (local development with persistence) services.AddSingleton(sp => new FolderFileStorage(o => o.Folder = "./storage") ); // Azure Blob (production) services.AddSingleton(sp => new AzureFileStorage(o => { o.ConnectionString = configuration["Azure:StorageConnectionString"]; o.ContainerName = "files"; }) ); ``` ### With Scoping ```csharp services.AddSingleton(sp => new FolderFileStorage(o => o.Folder = "/data") ); // Scoped storage per tenant services.AddScoped((sp, tenantId) => { var baseStorage = sp.GetRequiredService(); return new ScopedFileStorage(baseStorage, $"tenant:{tenantId}"); }); ``` ## Best Practices ### 1. Use Meaningful Paths ```csharp // ✅ Good: Organized, meaningful paths "documents/invoices/2024/01/invoice-12345.pdf" "users/user-123/avatars/profile.jpg" "temp/uploads/session-abc/file.tmp" // ❌ Bad: Flat, unclear paths "file1.pdf" "12345.pdf" "abc123" ``` ### 2. Include Extension in Path ```csharp // ✅ Good: Extension present await storage.SaveFileAsync("report.pdf", stream); // ❌ Bad: No extension await storage.SaveFileAsync("report", stream); ``` ### 3. Use Scoped Storage for Isolation ```csharp // Each tenant has isolated storage var tenantStorage = new ScopedFileStorage(baseStorage, tenantId); ``` ### 4. Handle Missing Files ```csharp if (!await storage.ExistsAsync(path)) { throw new FileNotFoundException($"File not found: {path}"); } var stream = await storage.GetFileStreamAsync(path, StreamMode.Read); ``` ### 5. Dispose Streams Properly ```csharp // ✅ Good: Using statement using var stream = await storage.GetFileStreamAsync(path, StreamMode.Read); await stream.CopyToAsync(destination); // ❌ Bad: Not disposing var stream = await storage.GetFileStreamAsync(path, StreamMode.Read); await stream.CopyToAsync(destination); // stream never disposed! ``` ### 6. Use Appropriate Storage for Use Case | Use Case | Recommended Storage | |----------|---------------------| | Development/Testing | InMemoryFileStorage | | Local persistence | FolderFileStorage | | Cloud applications | AzureFileStorage, S3FileStorage | | Multi-cloud | MinioFileStorage | | Legacy systems | SshNetFileStorage | ## FileSpec Properties [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Storage/FileSpec.cs) ```csharp public class FileSpec { public required string Path { get; set; } public long Size { get; set; } public DateTime Created { get; set; } public DateTime Modified { get; set; } } ``` ## Next Steps * [Caching](./caching) - Cache file metadata * [Jobs](./jobs) - Background file processing * [Azure Implementation](./implementations/azure) - Production Azure setup * [AWS Implementation](./implementations/aws) - Production AWS setup --- --- url: /README.md --- # Foundatio Documentation This is the documentation site for [Foundatio](https://github.com/FoundatioFx/Foundatio), built with [VitePress](https://vitepress.dev). ## Prerequisites * [Node.js](https://nodejs.org/) 18.x or higher * npm (comes with Node.js) ## Getting Started ### Install Dependencies ```bash npm install ``` ### Development Start the development server with hot-reload: ```bash npm run docs:dev ``` The site will be available at `http://localhost:5173`. ### Build Build the static site for production: ```bash npm run docs:build ``` The built files will be in `.vitepress/dist`. ### Preview Preview the production build locally: ```bash npm run docs:preview ``` ## Project Structure ```txt docs/ ├── .vitepress/ │ ├── config.ts # VitePress configuration │ └── dist/ # Built output (generated) ├── guide/ │ ├── what-is-foundatio.md │ ├── getting-started.md │ ├── why-foundatio.md │ ├── caching.md │ ├── queues.md │ ├── locks.md │ ├── messaging.md │ ├── storage.md │ ├── jobs.md │ ├── resilience.md │ ├── dependency-injection.md │ ├── configuration.md │ └── implementations/ │ ├── in-memory.md │ ├── redis.md │ ├── azure.md │ └── aws.md ├── index.md # Home page ├── package.json └── README.md # This file ``` ## Writing Documentation ### Adding New Pages 1. Create a new `.md` file in the appropriate directory 2. Add frontmatter with title: ```yaml --- title: Your Page Title --- ``` 3. Add the page to the sidebar in `.vitepress/config.ts` ### Code Blocks Use triple backticks with language identifier: ````markdown ```csharp var cache = new InMemoryCacheClient(); await cache.SetAsync("key", "value"); ``` ```` ### Admonitions VitePress supports custom containers: ```markdown ::: info Informational message ::: ::: tip Helpful tip ::: ::: warning Warning message ::: ::: danger Critical warning ::: ``` ### Internal Links Use relative paths for internal links: ```markdown [Getting Started](./getting-started) [Caching Guide](./caching.md) ``` ## Plugins This documentation site uses: * **vitepress-plugin-llms**: Generates LLM-friendly documentation at `/llms.txt` * **vitepress-plugin-mermaid**: Enables Mermaid diagrams in markdown ### Diagrams The documentation uses diagrams for visual explanations: ````markdown ​```txt ┌─────────┐ ┌──────────────┐ ┌──────────────┐ │ Request │────▶│ Local Cache │────▶│ Redis Cache │ └─────────┘ └──────────────┘ └──────────────┘ │ │ ▼ ▼ Cache Hit? Cache Hit? ​``` ```` ## Deployment The documentation can be deployed to any static hosting service: * GitHub Pages * Netlify * Vercel * Azure Static Web Apps * AWS S3 + CloudFront ### GitHub Pages 1. Build the site: `npm run docs:build` 2. Deploy `.vitepress/dist` to `gh-pages` branch ### Netlify / Vercel Connect your repository and configure: * Build command: `npm run docs:build` * Output directory: `.vitepress/dist` ## Contributing 1. Fork the repository 2. Create a feature branch 3. Make your changes 4. Run the dev server to preview 5. Submit a pull request ## License This documentation is part of the Foundatio project and is licensed under the same terms. --- --- url: /guide/implementations/aliyun.md --- # Foundatio.Aliyun Foundatio provides Alibaba Cloud OSS file storage implementation. [View source on GitHub →](https://github.com/FoundatioFx/Foundatio.Aliyun) ## Overview | Implementation | Interface | Package | |----------------|-----------|---------| | `AliyunFileStorage` | `IFileStorage` | Foundatio.Aliyun | ## Installation ```bash dotnet add package Foundatio.Aliyun ``` ## Usage ```csharp using Foundatio.Storage; var storage = new AliyunFileStorage(o => o.ConnectionString = connectionString); await storage.SaveFileAsync("documents/report.pdf", pdfStream); var stream = await storage.GetFileStreamAsync("documents/report.pdf", StreamMode.Read); ``` ## Configuration **Connection String** (required): ```csharp var storage = new AliyunFileStorage(o => { o.ConnectionString = "endpoint=oss-cn-hangzhou.aliyuncs.com;accessKeyId=xxx;accessKeySecret=yyy;bucket=my-bucket"; }); ``` For additional options including `LoggerFactory`, `Serializer`, `TimeProvider`, and `ResiliencePolicyProvider`, see the [AliyunFileStorageOptions source](https://github.com/FoundatioFx/Foundatio.Aliyun/blob/main/src/Foundatio.Aliyun/Storage/AliyunFileStorageOptions.cs). ## Next Steps * [File Storage Guide](/guide/storage) - Usage patterns and best practices * [Serialization](/guide/serialization) - Configure serialization --- --- url: /guide/implementations/aws.md --- # Foundatio.AWS Foundatio provides AWS implementations for file storage, queuing, and messaging using Amazon S3, Amazon SQS, and Amazon SNS. [View source on GitHub →](https://github.com/FoundatioFx/Foundatio.AWS) ## Overview | Implementation | Interface | Package | |----------------|-----------|---------| | `S3FileStorage` | `IFileStorage` | Foundatio.AWS | | `SQSMessageBus` | `IMessageBus` | Foundatio.AWS | | `SQSQueue` | `IQueue` | Foundatio.AWS | ## Installation ```bash dotnet add package Foundatio.AWS ``` ## S3FileStorage Store files in Amazon S3 with full support for buckets, prefixes, and metadata. ```csharp using Foundatio.Storage; var storage = new S3FileStorage(o => { o.ConnectionString = connectionString; // Or: o.Bucket = "my-files"; o.Region = RegionEndpoint.USEast1; }); await storage.SaveFileAsync("documents/report.pdf", pdfStream); ``` ### Configuration | Option | Type | Required | Description | |--------|------|----------|-------------| | `Bucket` | `string` | ✅ | S3 bucket name | | `Region` | `RegionEndpoint` | ✅ | AWS region | | `ConnectionString` | `string` | | Parses all settings | | `Credentials` | `AWSCredentials` | | AWS credentials | | `ServiceUrl` | `string` | | Custom endpoint (LocalStack) | For additional options, see [S3FileStorageOptions source](https://github.com/FoundatioFx/Foundatio.AWS/blob/main/src/Foundatio.AWS/Storage/S3FileStorageOptions.cs). ## SQSMessageBus AWS SNS/SQS message bus for pub/sub messaging using the SNS fan-out pattern. ```csharp using Foundatio.Messaging; var messageBus = new SQSMessageBus(o => { o.ConnectionString = connectionString; o.Topic = "events"; // Optional: Specify queue name for durable subscriptions // o.SubscriptionQueueName = "my-service-queue"; }); await messageBus.SubscribeAsync(async order => { Console.WriteLine($"Order created: {order.OrderId}"); }); await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }); ``` ### Configuration | Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `Topic` | `string` | ✅ | | SNS topic name for publishing | | `ConnectionString` | `string` | | | Connection string | | `Credentials` | `AWSCredentials` | | | AWS credentials | | `Region` | `RegionEndpoint` | | | AWS region | | `ServiceUrl` | `string` | | | Custom endpoint (LocalStack) | | `CanCreateTopic` | `bool` | | `true` | Auto-create SNS topic if missing | | `SubscriptionQueueName` | `string` | | Random | SQS queue name (use for durable subscriptions) | | `SubscriptionQueueAutoDelete` | `bool` | | `true` | Auto-delete queue on dispose (set `false` for durable) | | `ReadQueueTimeout` | `TimeSpan` | | 20s | Long polling timeout | | `DequeueInterval` | `TimeSpan` | | 1s | Interval between dequeue attempts | | `MessageVisibilityTimeout` | `TimeSpan?` | | 30s (SQS) | Message visibility timeout | | `SqsManagedSseEnabled` | `bool` | | `false` | Enable SQS managed encryption (SSE-SQS) | | `KmsMasterKeyId` | `string` | | | KMS key ID for encryption (SSE-KMS) | | `KmsDataKeyReusePeriodSeconds` | `int` | | 300 | KMS key reuse period | | `TopicResolver` | `Func` | | | Route message types to different topics | For additional options, see [SQSMessageBusOptions source](https://github.com/FoundatioFx/Foundatio.AWS/blob/main/src/Foundatio.AWS/Messaging/SQSMessageBusOptions.cs). ### Architecture The `SQSMessageBus` uses the SNS fan-out pattern: * **Publishing**: Messages are published to an SNS topic * **Subscribing**: Each subscriber gets its own SQS queue subscribed to the SNS topic * **Durable Subscriptions**: Use `SubscriptionQueueName` and set `SubscriptionQueueAutoDelete = false` to persist queues across restarts * **Policy Management**: Queue policies are automatically configured to allow SNS to deliver messages ### Durable Subscriptions Example ```csharp var messageBus = new SQSMessageBus(o => { o.ConnectionString = connectionString; o.Topic = "events"; o.SubscriptionQueueName = "order-service-events"; o.SubscriptionQueueAutoDelete = false; // Queue persists across restarts }); ``` ## SQSQueue AWS SQS queue implementation for reliable work item processing. ```csharp using Foundatio.Queues; var queue = new SQSQueue(o => { o.ConnectionString = connectionString; o.Name = "work-items"; }); await queue.EnqueueAsync(new WorkItem { Data = "Hello" }); var entry = await queue.DequeueAsync(); ``` ### Configuration | Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `Name` | `string` | ✅ | | Queue name | | `ConnectionString` | `string` | | | Connection string | | `Region` | `RegionEndpoint` | | | AWS region | | `CanCreateQueue` | `bool` | | `true` | Auto-create queue | | `SupportDeadLetter` | `bool` | | `true` | Enable DLQ support | | `ReadQueueTimeout` | `TimeSpan` | | 20s | Long polling timeout | For additional options, see [SQSQueueOptions source](https://github.com/FoundatioFx/Foundatio.AWS/blob/main/src/Foundatio.AWS/Queues/SQSQueueOptions.cs). ## Next Steps * [File Storage Guide](/guide/storage) - Usage patterns * [Queues Guide](/guide/queues) - Queue processing patterns * [Messaging Guide](/guide/messaging) - Pub/sub patterns and best practices * [Serialization](/guide/serialization) - Configure serialization --- --- url: /guide/implementations/azure.md --- # Foundatio.AzureStorage / Foundatio.AzureServiceBus Foundatio provides Azure implementations for storage, queuing, and messaging using Azure Blob Storage, Azure Storage Queues, and Azure Service Bus. [View source on GitHub →](https://github.com/FoundatioFx/Foundatio.AzureStorage) | [AzureServiceBus](https://github.com/FoundatioFx/Foundatio.AzureServiceBus) ## Overview | Implementation | Interface | Package | |----------------|-----------|---------| | `AzureFileStorage` | `IFileStorage` | Foundatio.AzureStorage | | `AzureStorageQueue` | `IQueue` | Foundatio.AzureStorage | | `AzureServiceBusQueue` | `IQueue` | Foundatio.AzureServiceBus | | `AzureServiceBusMessageBus` | `IMessageBus` | Foundatio.AzureServiceBus | ## Installation ```bash # Azure Storage (Blob, Storage Queues) dotnet add package Foundatio.AzureStorage # Azure Service Bus (Queues, Messaging) dotnet add package Foundatio.AzureServiceBus ``` ## AzureFileStorage Azure Blob Storage file storage. ```csharp using Foundatio.Storage; var storage = new AzureFileStorage(o => { o.ConnectionString = connectionString; o.ContainerName = "files"; }); await storage.SaveFileAsync("documents/report.pdf", pdfStream); ``` ### Configuration | Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `ConnectionString` | `string` | ✅ | | Azure Storage connection string | | `ContainerName` | `string` | | `"storage"` | Blob container name | For additional options, see [AzureFileStorageOptions source](https://github.com/FoundatioFx/Foundatio.AzureStorage/blob/main/src/Foundatio.AzureStorage/Storage/AzureFileStorageOptions.cs). ## AzureStorageQueue Azure Storage Queue implementation. ```csharp using Foundatio.Queues; var queue = new AzureStorageQueue(o => { o.ConnectionString = connectionString; o.Name = "work-items"; }); await queue.EnqueueAsync(new WorkItem { Data = "Hello" }); ``` ### Configuration | Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `ConnectionString` | `string` | ✅ | | Azure Storage connection string | | `DequeueInterval` | `TimeSpan` | | 2s | Polling interval | For additional options, see [AzureStorageQueueOptions source](https://github.com/FoundatioFx/Foundatio.AzureStorage/blob/main/src/Foundatio.AzureStorage/Queues/AzureStorageQueueOptions.cs). ## AzureServiceBusQueue Azure Service Bus queue with advanced features. ```csharp using Foundatio.Queues; var queue = new AzureServiceBusQueue(o => { o.ConnectionString = connectionString; o.Name = "work-items"; }); await queue.EnqueueAsync(new WorkItem { Data = "Hello" }); ``` ### Configuration | Option | Type | Required | Description | |--------|------|----------|-------------| | `ConnectionString` | `string` | ✅ | Service Bus connection string | | `RequiresSession` | `bool?` | | Enable sessions for ordered processing | | `RequiresDuplicateDetection` | `bool?` | | Enable duplicate detection | | `EnableDeadLetteringOnMessageExpiration` | `bool?` | | DLQ on expiration | For additional options, see [AzureServiceBusQueueOptions source](https://github.com/FoundatioFx/Foundatio.AzureServiceBus/blob/main/src/Foundatio.AzureServiceBus/Queues/AzureServiceBusQueueOptions.cs). ## AzureServiceBusMessageBus Azure Service Bus pub/sub messaging. ```csharp using Foundatio.Messaging; var messageBus = new AzureServiceBusMessageBus(o => { o.ConnectionString = connectionString; o.Topic = "events"; o.SubscriptionName = "my-service"; }); await messageBus.SubscribeAsync(async order => { Console.WriteLine($"Order created: {order.OrderId}"); }); await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }); ``` ### Configuration | Option | Type | Required | Description | |--------|------|----------|-------------| | `ConnectionString` | `string` | ✅ | Service Bus connection string | | `SubscriptionName` | `string` | | Subscription name (unique per consumer) | | `PrefetchCount` | `int?` | | Message prefetch count | For additional options, see [AzureServiceBusMessageBusOptions source](https://github.com/FoundatioFx/Foundatio.AzureServiceBus/blob/main/src/Foundatio.AzureServiceBus/Messaging/AzureServiceBusMessageBusOptions.cs). ## Next Steps * [File Storage Guide](/guide/storage) - Usage patterns * [Queues Guide](/guide/queues) - Queue processing patterns * [Messaging Guide](/guide/messaging) - Pub/sub patterns * [Serialization](/guide/serialization) - Configure serialization --- --- url: /guide/implementations/kafka.md --- # Foundatio.Kafka Foundatio provides Apache Kafka messaging for high-throughput event streaming. [View source on GitHub →](https://github.com/FoundatioFx/Foundatio.Kafka) ## Overview | Implementation | Interface | Package | |----------------|-----------|---------| | `KafkaMessageBus` | `IMessageBus` | Foundatio.Kafka | ## Installation ```bash dotnet add package Foundatio.Kafka ``` ## Usage ```csharp using Foundatio.Messaging; var messageBus = new KafkaMessageBus(o => { o.BootstrapServers = "localhost:9092"; o.Topic = "events"; o.GroupId = "my-service"; }); await messageBus.SubscribeAsync(async order => { Console.WriteLine($"Order created: {order.OrderId}"); }); await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }); ``` ## Configuration | Option | Type | Required | Description | |--------|------|----------|-------------| | `BootstrapServers` | `string` | ✅ | Kafka broker addresses | | `GroupId` | `string` | ✅ | Consumer group ID | | `SecurityProtocol` | `SecurityProtocol?` | | Security protocol | | `SaslMechanism` | `SaslMechanism?` | | SASL mechanism | | `SaslUsername` | `string` | | SASL username | | `SaslPassword` | `string` | | SASL password | For additional options, see [KafkaMessageBusOptions source](https://github.com/FoundatioFx/Foundatio.Kafka/blob/main/src/Foundatio.Kafka/Messaging/KafkaMessageBusOptions.cs). ## Next Steps * [Messaging Guide](/guide/messaging) - Pub/sub patterns and best practices * [Serialization](/guide/serialization) - Configure serialization --- --- url: /guide/implementations/minio.md --- # Foundatio.Minio Foundatio provides Minio file storage for S3-compatible object storage. [View source on GitHub →](https://github.com/FoundatioFx/Foundatio.Minio) ## Overview | Implementation | Interface | Package | |----------------|-----------|---------| | `MinioFileStorage` | `IFileStorage` | Foundatio.Minio | ## Installation ```bash dotnet add package Foundatio.Minio ``` ## Usage ```csharp using Foundatio.Storage; var storage = new MinioFileStorage(o => o.ConnectionString = "endpoint=play.min.io;accessKey=minioadmin;secretKey=minioadmin;bucket=my-bucket"); await storage.SaveFileAsync("documents/report.pdf", pdfStream); var stream = await storage.GetFileStreamAsync("documents/report.pdf", StreamMode.Read); ``` ## Configuration | Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `ConnectionString` | `string` | ✅ | | Connection string | | `AutoCreateBucket` | `bool` | | `false` | Auto-create bucket if missing | For additional options, see [MinioFileStorageOptions source](https://github.com/FoundatioFx/Foundatio.Minio/blob/main/src/Foundatio.Minio/Storage/MinioFileStorageOptions.cs). ## Next Steps * [File Storage Guide](/guide/storage) - Usage patterns and best practices * [Serialization](/guide/serialization) - Configure serialization --- --- url: /guide/implementations/rabbitmq.md --- # Foundatio.RabbitMQ Foundatio provides RabbitMQ messaging for pub/sub with durable delivery. [View source on GitHub →](https://github.com/FoundatioFx/Foundatio.RabbitMQ) ## Overview | Implementation | Interface | Package | |----------------|-----------|---------| | `RabbitMQMessageBus` | `IMessageBus` | Foundatio.RabbitMQ | ## Installation ```bash dotnet add package Foundatio.RabbitMQ ``` ## Usage ```csharp using Foundatio.Messaging; var messageBus = new RabbitMQMessageBus(o => { o.ConnectionString = "amqp://guest:guest@localhost:5672"; o.Topic = "events"; }); await messageBus.SubscribeAsync(async order => { Console.WriteLine($"Order created: {order.OrderId}"); }); await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }); ``` ## Configuration | Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `ConnectionString` | `string` | ✅ | | RabbitMQ connection string | | `IsDurable` | `bool` | | `true` | Durable messages | | `DeliveryLimit` | `long` | | `2` | Max delivery attempts | | `AcknowledgementStrategy` | `AcknowledgementStrategy` | | `FireAndForget` | Ack strategy | | `PrefetchCount` | `ushort` | | `0` | Consumer prefetch count | For additional options, see [RabbitMQMessageBusOptions source](https://github.com/FoundatioFx/Foundatio.RabbitMQ/blob/main/src/Foundatio.RabbitMQ/Messaging/RabbitMQMessageBusOptions.cs). ## Delayed message delivery You can schedule delivery using `DeliveryDelay` on publish options (see the [messaging guide](/guide/messaging)). **Behavior depends on the broker and the deprecated plugin:** 1. **RabbitMQ before 4.3 with the plugin installed** — If [`rabbitmq_delayed_message_exchange`](https://github.com/rabbitmq/rabbitmq-delayed-message-exchange/) is present, Foundatio uses it and logs a deprecation warning at startup. The plugin is archived and will not work on RabbitMQ 4.3 and later. 2. **RabbitMQ before 4.3 without the plugin** — Delayed sends fall back to the in-memory scheduler in `MessageBusBase`. **Not durable** across process restarts. 3. **RabbitMQ 4.3 and later** — The delayed-exchange probe is skipped (the plugin is incompatible). The same in-memory fallback applies automatically. For durable delayed delivery on newer brokers, plan a migration (for example TTL + dead-letter exchanges or an external scheduler). See the [Foundatio.RabbitMQ README](https://github.com/FoundatioFx/Foundatio.RabbitMQ/blob/main/README.md) for the full note. ## Next Steps * [Messaging Guide](/guide/messaging) - Pub/sub patterns and best practices * [Serialization](/guide/serialization) - Configure serialization --- --- url: /guide/implementations/sshnet.md --- # Foundatio.Storage.SshNet Foundatio provides SFTP-based file storage via SSH.NET. [View source on GitHub →](https://github.com/FoundatioFx/Foundatio.Storage.SshNet) ## Overview | Implementation | Interface | Package | |----------------|-----------|---------| | `SshNetFileStorage` | `IFileStorage` | Foundatio.Storage.SshNet | ## Installation ```bash dotnet add package Foundatio.Storage.SshNet ``` ## Usage ```csharp using Foundatio.Storage; var storage = new SshNetFileStorage(o => o.ConnectionString = "host=sftp.example.com;username=user;password=pass;path=/uploads"); await storage.SaveFileAsync("documents/report.pdf", pdfStream); var stream = await storage.GetFileStreamAsync("documents/report.pdf", StreamMode.Read); ``` ## Configuration | Option | Type | Required | Description | |--------|------|----------|-------------| | `ConnectionString` | `string` | ✅ | Connection string | | `PrivateKey` | `Stream` | | Private key for authentication | | `PrivateKeyPassPhrase` | `string` | | Private key passphrase | | `Proxy` | `string` | | Proxy server | | `ProxyType` | `ProxyTypes` | | Proxy type | For additional options, see [SshNetFileStorageOptions source](https://github.com/FoundatioFx/Foundatio.Storage.SshNet/blob/main/src/Foundatio.Storage.SshNet/Storage/SshNetFileStorageOptions.cs). ## Next Steps * [File Storage Guide](/guide/storage) - Usage patterns and best practices * [Serialization](/guide/serialization) - Configure serialization --- --- url: /guide/getting-started.md --- # Getting Started This guide will walk you through installing Foundatio and using your first abstractions. ## Installation Foundatio is available on [NuGet](https://www.nuget.org/packages?q=Foundatio). Install the core package: ```bash dotnet add package Foundatio ``` For specific implementations, install the corresponding packages: ```bash # Redis implementations dotnet add package Foundatio.Redis # Azure Storage (Queues, Blobs) dotnet add package Foundatio.AzureStorage # Azure Service Bus (Queues, Messaging) dotnet add package Foundatio.AzureServiceBus # AWS (SQS, S3) dotnet add package Foundatio.AWS # RabbitMQ (Messaging) dotnet add package Foundatio.RabbitMQ # Kafka (Messaging) dotnet add package Foundatio.Kafka # Aliyun OSS (Storage) dotnet add package Foundatio.Aliyun # MinIO (S3-compatible Storage) dotnet add package Foundatio.Minio # SSH/SFTP (Storage) dotnet add package Foundatio.Storage.SshNet ``` ## Basic Setup ### 1. Register Services Configure Foundatio services in your application's dependency injection container: ```csharp using Foundatio.Caching; using Foundatio.Messaging; using Foundatio.Lock; using Foundatio.Storage; using Foundatio.Queues; var builder = WebApplication.CreateBuilder(args); // Register core services builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Register lock provider (depends on cache and message bus) builder.Services.AddSingleton(sp => new CacheLockProvider( sp.GetRequiredService(), sp.GetRequiredService() ) ); // Register queues builder.Services.AddSingleton>(sp => new InMemoryQueue() ); var app = builder.Build(); ``` ### 2. Use the Services Inject and use the services in your application: ```csharp public class OrderService { private readonly ICacheClient _cache; private readonly IQueue _queue; private readonly ILockProvider _locker; private readonly IMessageBus _messageBus; public OrderService( ICacheClient cache, IQueue queue, ILockProvider locker, IMessageBus messageBus) { _cache = cache; _queue = queue; _locker = locker; _messageBus = messageBus; } public async Task CreateOrderAsync(CreateOrderRequest request) { // Use distributed lock to prevent duplicate orders await using var lck = await _locker.AcquireAsync($"order:{request.CustomerId}"); if (lck == null) throw new InvalidOperationException("Could not acquire lock"); // Create order var order = new Order { Id = Guid.NewGuid(), CustomerId = request.CustomerId }; // Cache the order await _cache.SetAsync($"order:{order.Id}", order, TimeSpan.FromHours(1)); // Queue for background processing await _queue.EnqueueAsync(new OrderWorkItem { OrderId = order.Id }); // Publish event for other services await _messageBus.PublishAsync(new OrderCreatedEvent { OrderId = order.Id }); return order; } } ``` ## Switching to Production Implementations When moving to production, swap in-memory implementations for distributed ones: ```csharp using Foundatio.Redis.Cache; using Foundatio.Redis.Messaging; using Foundatio.Redis.Queues; using StackExchange.Redis; var builder = WebApplication.CreateBuilder(args); // Configure Redis connection var redis = await ConnectionMultiplexer.ConnectAsync("localhost:6379"); builder.Services.AddSingleton(redis); // Use Redis implementations builder.Services.AddSingleton(sp => new RedisCacheClient(o => o.ConnectionMultiplexer = redis) ); builder.Services.AddSingleton(sp => new RedisMessageBus(o => o.Subscriber = redis.GetSubscriber()) ); builder.Services.AddSingleton>(sp => new RedisQueue(o => o.ConnectionMultiplexer = redis) ); ``` Your application code remains unchanged - only the DI registration changes! ## Working with Extension Methods Foundatio provides convenient extension methods through `FoundatioServicesExtensions`: ```csharp using Foundatio; var builder = WebApplication.CreateBuilder(args); // Add Foundatio with default in-memory implementations builder.Services.AddFoundatio(); // Or configure with options builder.Services.AddFoundatio(options => { options.UseInMemoryCache(); options.UseInMemoryMessageBus(); options.UseInMemoryQueues(); options.UseInMemoryStorage(); }); ``` ## Sample Application Here's a complete example showing all major abstractions working together: ```csharp using Foundatio.Caching; using Foundatio.Lock; using Foundatio.Messaging; using Foundatio.Queues; using Foundatio.Storage; // Setup services var cache = new InMemoryCacheClient(); var messageBus = new InMemoryMessageBus(); var storage = new InMemoryFileStorage(); var locker = new CacheLockProvider(cache, messageBus); var queue = new InMemoryQueue(); // Subscribe to messages await messageBus.SubscribeAsync(msg => { Console.WriteLine($"Work completed: {msg.ItemId}"); }); // Store a file await storage.SaveFileAsync("config.json", """{"setting": "value"}"""); // Queue work await queue.EnqueueAsync(new WorkItem { Id = "item-1" }); // Process queue with locking while (true) { var entry = await queue.DequeueAsync(TimeSpan.FromSeconds(5)); if (entry == null) break; // Acquire lock for this item await using var lck = await locker.AcquireAsync($"work:{entry.Value.Id}"); if (lck != null) { // Cache progress await cache.SetAsync($"progress:{entry.Value.Id}", "processing"); // Do work... // Complete entry await entry.CompleteAsync(); // Publish completion event await messageBus.PublishAsync(new WorkCompleted { ItemId = entry.Value.Id }); } else { // Couldn't get lock, abandon for retry await entry.AbandonAsync(); } } public record WorkItem { public string Id { get; init; } } public record WorkCompleted { public string ItemId { get; init; } } ``` ## Next Steps Now that you have the basics working, explore more advanced features: * [Caching](./caching) - Deep dive into caching patterns * [Queues](./queues) - Queue processing and behaviors * [Locks](./locks) - Distributed locking strategies * [Messaging](./messaging) - Pub/sub patterns * [Storage](./storage) - File storage operations * [Jobs](./jobs) - Background job processing * [Resilience](./resilience) - Retry policies and circuit breakers ## LLM-Friendly Documentation For AI assistants and Large Language Models, we provide optimized documentation formats: * [📜 LLMs Index](/llms.txt) - Quick reference with links to all sections * [📖 Complete Documentation](/llms-full.txt) - All docs in one LLM-friendly file These files follow the [llmstxt.org](https://llmstxt.org/) standard and contain the same information as this documentation in a format optimized for AI consumption. --- --- url: /guide/implementations/hybrid-cache.md --- # Hybrid Cache The `HybridCacheClient` combines a local in-memory cache with a distributed cache for maximum performance. It's ideal for read-heavy workloads where the same data is accessed frequently across multiple requests. **GitHub**: [Foundatio](https://github.com/FoundatioFx/Foundatio) (included in core package) ## Overview | Feature | Description | |---------|-------------| | **Local Cache** | Fast in-process memory (no network) | | **Distributed Cache** | Shared state across instances (Redis, etc.) | | **Invalidation** | Automatic key-specific invalidation via message bus | | **Best For** | Read-heavy, low-write workloads | ## Installation Hybrid cache is included in the core Foundatio package: ```bash dotnet add package Foundatio ``` For Redis-based hybrid cache: ```bash dotnet add package Foundatio.Redis ``` ## How It Works ### Read Flow ```txt ┌─────────┐ ┌──────────────┐ ┌────────────────────┐ │ Request │────▶│ Local Cache │────▶│ Distributed Cache │ └─────────┘ └──────────────┘ └────────────────────┘ │ │ ▼ ▼ Cache Hit? Cache Hit? │ │ Yes: Return Yes: Store in Local Then: Return ``` 1. Check local in-memory cache first (zero network latency) 2. On miss, check distributed cache (Redis, etc.) 3. If found in distributed cache, store in local cache for future requests 4. Return value to caller ### Write Flow ```txt ┌──────────────────┐ │ Write Operation │ └────────┬─────────┘ │ ▼ ┌────────────────────┐ │ Distributed Cache │ ◀── Write to L2 first (source of truth) │ (L2 - Update) │ └────────┬───────────┘ │ │ Success? │ ┌─────┴─────┐ │ │ ▼ ▼ Yes No │ │ ▼ │ ┌──────────────┐ │ │ Local Cache │ │ │ (L1 - Update)│ │ └──────────────┘ │ │ │ └─────┬─────┘ │ ▼ ┌──────────────────┐ │ Message Bus │ │ (Invalidation) │ └────────┬─────────┘ │ ▼ ┌─────────────────────────┐ │ Other Hybrid Cache │ │ Instances: Clear │ │ SPECIFIC Keys Only │ └─────────────────────────┘ ``` 1. Write to distributed cache first (L2 is the source of truth) 2. Only update local cache (L1) if distributed write succeeds 3. Publish invalidation message via message bus 4. **Other instances** receive message and clear **only the affected keys** from their local cache 5. Current instance ignores its own invalidation messages (filtered by `CacheId`) ### Key-Specific Invalidation Invalidation is **surgical** - only the affected keys are cleared on other instances, not the entire cache: | Operation | Invalidation Scope | |-----------|-------------------| | `SetAsync("user:123", ...)` | Clears `user:123` on other instances | | `SetAllAsync(dict)` | Clears all specified keys | | `RemoveAsync("user:123")` | Clears `user:123` on other instances | | `RemoveByPrefixAsync("user:")` | Clears `user:*` pattern on other instances | | `RemoveAllAsync()` (no keys) | Clears **entire** local cache on all instances | ### Smart Cache Invalidation The hybrid cache optimizes message bus traffic by **only publishing invalidation messages when the distributed cache actually changed**. This reduces unnecessary network traffic and processing overhead. | Operation | Publishes When | |-----------|---------------| | `RemoveAsync(key)` | Key existed and was removed | | `RemoveIfEqualAsync(key, expected)` | Key existed with matching value and was removed | | `RemoveAllAsync(keys)` | At least one key was removed | | `RemoveAllAsync()` (flush) | At least one key was removed | | `RemoveByPrefixAsync(prefix)` | At least one key matched and was removed | | `ListRemoveAsync(key, values)` | At least one value was removed from the list | **Example**: If you call `RemoveAsync("user:123")` but the key doesn't exist in the distributed cache, no invalidation message is published because there's nothing for other instances to clear. This optimization is safe because: 1. **Distributed cache is the source of truth** - if a key doesn't exist there, it shouldn't exist in any local cache 2. **Local caches are eventually consistent** - expired entries are cleaned up naturally 3. **Redis handles expiration automatically** - expired keys are already removed, so `KeyDeleteAsync` returns `false` only when the key truly doesn't exist ## L1/L2 Cache Architecture `HybridCacheClient` implements a two-tier caching architecture following industry-standard terminology: | Tier | Name | Implementation | Characteristics | |------|------|----------------|-----------------| | **L1** | Local Cache | `InMemoryCacheClient` | Fast (no network), per-instance, volatile | | **L2** | Distributed Cache | Redis, etc. | Shared across instances, source of truth | This architecture is similar to Microsoft's `HybridCache` (.NET 9+) and other distributed caching solutions like EasyCaching. ### Consistency Model `HybridCacheClient` uses a **write-through** pattern to ensure consistency: 1. **Distributed-first writes**: All write operations go to L2 (distributed cache) first 2. **Conditional local update**: L1 (local cache) is only updated if L2 succeeds 3. **Cross-instance invalidation**: Message bus notifies other instances to clear affected keys This ensures that: * L2 is always the **source of truth** * L1 never contains data that doesn't exist in L2 * Failed distributed writes don't leave stale data in local cache ::: info TTL Skew Between L1 and L2 When setting expiration times, there is a small timing skew between L1 and L2: 1. L2 (distributed cache) sets TTL at time T 2. Network latency and processing occur 3. L1 (local cache) sets TTL at time T + delta This means L1 may expire slightly **after** L2, potentially serving stale data for a brief window (typically milliseconds). For most use cases, this is negligible. If sub-second TTL accuracy is critical, consider raising a PR. ::: ### Local Cache Synchronization Strategies Different operations use different strategies to keep L1 in sync with L2: | Strategy | When Used | Operations | |----------|-----------|------------| | **Set on success** | When we know the exact value after the operation | `SetAsync`, `ReplaceAsync`, `IncrementAsync` | | **Set on full success** | When all items in a batch succeed | `ListAddAsync`, `ListRemoveAsync` (when count matches) | | **Remove to invalidate** | When the final value is uncertain or partial success | `SetIfHigherAsync`, `SetIfLowerAsync`, partial `ListAddAsync`/`ListRemoveAsync` | | **Remove on failure** | When the operation fails (e.g., past expiration) | `SetAsync`, `SetAllAsync`, `ReplaceAsync`, `ReplaceIfEqualAsync` | **Set on success** - Used when the operation's result is deterministic: ```csharp // After successful distributed write, we know the exact value await distributedCache.SetAsync(key, value); await localCache.SetAsync(key, value); // Same value, guaranteed consistent // IncrementAsync returns the new value, so we can cache it long newValue = await distributedCache.IncrementAsync(key, amount, TimeSpan.FromMinutes(5)); await localCache.SetAsync(key, newValue, TimeSpan.FromMinutes(5)); // Cache the authoritative value // IncrementAsync with null expiration removes TTL (consistent with SetAsync) long newValue = await distributedCache.IncrementAsync(key, amount, null); await localCache.SetAsync(key, newValue, null); // Both caches: no expiration ``` **Set on full success** - Used for batch operations when all items succeed: ```csharp // ListAddAsync: if all items were added, update local cache long added = await distributedCache.ListAddAsync(key, items, expiresIn); if (added == items.Length) await localCache.ListAddAsync(key, items, expiresIn); // Full success else await localCache.RemoveAsync(key); // Partial success - force re-fetch ``` **Remove to invalidate** - Used for conditional operations where we don't know the actual value: ```csharp // SetIfHigherAsync: even when difference == 0, we don't know the actual current value // We only know our value wasn't higher, not what the distributed cache contains double difference = await distributedCache.SetIfHigherAsync(key, value, expiresIn); if (difference > 0) await localCache.SetAsync(key, value, expiresIn); // Value was updated else await localCache.RemoveAsync(key); // Value wasn't updated - force re-fetch ``` **Remove on failure** - Ensures local cache doesn't contain stale data when distributed operation fails: ```csharp // If ReplaceAsync fails (e.g., past expiration removes the key), remove from local bool replaced = await distributedCache.ReplaceAsync(key, value, expiresIn); if (!replaced) await localCache.RemoveAsync(key); ``` This approach ensures consistency even when: * Local and distributed caches have different values * Conditional operations have partial success (e.g., list operations) * Multiple instances are writing concurrently * Operations fail due to past expiration ### Edge Cases with Zero Values The `IncrementAsync` operation has an edge case where L1 and L2 may temporarily be inconsistent when the result is 0: **IncrementAsync returning 0:** * If `IncrementAsync` returns 0 (e.g., incrementing 5 by -5, or creating a new key with amount 0), the key is removed from L1 rather than cached * This is a conservative approach since 0 could indicate either a legitimate value or an error condition * **Self-healing**: The next `GetAsync` will fetch from L2 and populate L1 This edge case is rare in practice and the inconsistency is temporary. The design prioritizes: 1. **Safety**: Removing uncertain values prevents serving stale data 2. **Simplicity**: Avoiding complex state tracking for rare edge cases 3. **Self-healing**: Any inconsistency is automatically resolved on the next read ## Basic Usage ### With Generic HybridCacheClient ```csharp using Foundatio.Caching; using Foundatio.Messaging; // Create dependencies var distributedCache = new RedisCacheClient(o => o.ConnectionMultiplexer = redis); var messageBus = new RedisMessageBus(o => o.Subscriber = redis.GetSubscriber()); // Create hybrid cache var hybridCache = new HybridCacheClient( distributedCache, messageBus, new InMemoryCacheClientOptions { MaxItems = 1000 } ); // First access: fetches from Redis, caches locally var user = await hybridCache.GetAsync("user:123"); // Subsequent access: returns from local cache (no network call) var sameUser = await hybridCache.GetAsync("user:123"); ``` ### With RedisHybridCacheClient (Convenience) ```csharp using Foundatio.Redis.Cache; using StackExchange.Redis; var redis = await ConnectionMultiplexer.ConnectAsync("localhost:6379"); // All-in-one Redis hybrid cache var hybridCache = new RedisHybridCacheClient( redisConfig => redisConfig.ConnectionMultiplexer(redis), localConfig => localConfig.MaxItems(1000) ); ``` ## Configuration ### Local Cache Options The local cache is an `InMemoryCacheClient`. Configure via `localCacheOptions`: ```csharp var hybridCache = new HybridCacheClient( distributedCache, messageBus, new InMemoryCacheClientOptions { // Maximum items (LRU eviction when exceeded) MaxItems = 1000, // Clone values to prevent reference sharing bugs // See: /guide/implementations/in-memory#clonevalues CloneValues = false, // Default: false // Custom serializer for cloning Serializer = mySerializer, // Logger factory LoggerFactory = loggerFactory } ); ``` For memory-based eviction and other advanced options, see [In-Memory Implementation](/guide/implementations/in-memory). ### Message Bus Topic ::: warning Shared Topic By default, all `HybridCacheClient` instances share the same message bus topic. In high-write scenarios, consider using separate message bus instances with different topics to isolate invalidation traffic by feature area (e.g., separate topics for user cache vs order cache). ::: ## Monitoring Access local cache statistics: ```csharp // Local cache hits (reads served from local cache) Console.WriteLine($"Local cache hits: {hybridCache.LocalCacheHits}"); // Current local cache item count Console.WriteLine($"Local cache count: {hybridCache.LocalCache.Count}"); // Number of invalidation messages received from other instances Console.WriteLine($"Invalidation calls: {hybridCache.InvalidateCacheCalls}"); ``` ## Performance Considerations ### Message Bus Traffic ::: warning Shared Topic Traffic `HybridCacheClient` publishes an invalidation message for **every write operation**. In high-write scenarios with a shared topic, this can generate significant traffic across all instances. ::: **The problem:** ```csharp // Each write publishes an InvalidateCache message to ALL instances await hybridCache.SetAsync("key1", value1); // 1 message to all await hybridCache.SetAsync("key2", value2); // 1 message to all await hybridCache.SetAsync("key3", value3); // 1 message to all // 1000 writes = 1000 messages to ALL instances ``` **Impact:** * With 10 instances and 1000 writes/second = 10,000 messages/second total * Every instance processes every invalidation message (even if irrelevant) * Can overwhelm Redis pub/sub or other message bus implementations **Solutions:** **1. Use separate scoped hybrid cache client/message bus topics per model type** (recommended): Consider isolating invalidation traffic by using separate message bus instances with different topics for unrelated caching concerns. **2. Use `HybridAwareCacheClient` for write-heavy services:** ```csharp // Background processor that writes lots of data // No local cache, just publishes invalidations var processor = new HybridAwareCacheClient(redisCache, messageBus); // Web servers that read data // Has local cache, receives invalidations var webCache = new HybridCacheClient(redisCache, messageBus); ``` **3. Batch writes when possible:** ```csharp // ❌ Individual sets = N messages foreach (var user in users) await cache.SetAsync($"user:{user.Id}", user); // ✅ SetAllAsync = 1 message (with all keys) await cache.SetAllAsync(users.ToDictionary(u => $"user:{u.Id}", u => u)); ``` **4. Consider if you need hybrid caching:** ```csharp // Write-heavy, read-once data: just use distributed cache var cache = new RedisCacheClient(o => o.ConnectionMultiplexer = redis); // Read-heavy, rarely-written data: hybrid is beneficial var hybridCache = new HybridCacheClient(cache, messageBus); ``` ### Local Cache Memory The local cache can consume significant memory. Always configure limits: ```csharp var hybridCache = new HybridCacheClient( distributedCache, messageBus, new InMemoryCacheClientOptions { MaxItems = 1000 // LRU eviction when exceeded } ); ``` For memory-based limits, see [In-Memory - Memory-Based Eviction](/guide/implementations/in-memory#memory-based-eviction). ## Related Interfaces ### IHybridCacheClient Implemented by `HybridCacheClient`. Marker interface for caches that combine local and distributed storage with automatic invalidation. ### IHybridAwareCacheClient Implemented by `HybridAwareCacheClient`. Wraps a distributed cache and publishes invalidation messages **without maintaining a local cache**: ```csharp // Service that only writes (e.g., background processor) var cacheWriter = new HybridAwareCacheClient( distributedCacheClient: redisCacheClient, messagePublisher: redisMessageBus ); // Write goes to Redis AND notifies all HybridCacheClient instances await cacheWriter.SetAsync("user:123", user); // Other services using HybridCacheClient will clear their local "user:123" cache ``` **Use cases:** * Background processors that write data but don't need local caching * Services that need to notify `HybridCacheClient` instances to invalidate * Write-heavy services where local caching would be wasteful ### IMemoryCacheClient Marker interface for in-memory cache implementations. Used for type checking and DI scenarios: ```csharp // Register specific implementation type services.AddSingleton(); // Inject when you specifically need in-memory behavior public class MyService(IMemoryCacheClient localCache) { } ``` ## When to Use Hybrid Cache ### ✅ Good Use Cases * **Configuration data**: Rarely changes, frequently read * **User profiles**: Read on every request, updated occasionally * **Product catalogs**: High read volume, batch updates * **Reference data**: Lookup tables, enums, static data * **Session data**: Read-heavy with occasional updates ### ⚠️ Consider Alternatives * **High-write workloads**: Use distributed cache only (no invalidation traffic) * **Large objects**: Serialization overhead may outweigh benefits * **Single instance**: Just use `InMemoryCacheClient` * **Real-time data**: Invalidation latency may be unacceptable ## DI Registration ```csharp // Register dependencies services.AddSingleton( ConnectionMultiplexer.Connect("localhost:6379")); services.AddSingleton(sp => { var redis = sp.GetRequiredService(); return new RedisHybridCacheClient( redisConfig => redisConfig .ConnectionMultiplexer(redis) .LoggerFactory(sp.GetRequiredService()), localConfig => localConfig.MaxItems(1000) ); }); ``` ## Next Steps * [In-Memory Implementation](/guide/implementations/in-memory) - Local cache configuration options * [Redis Implementation](/guide/implementations/redis) - Distributed cache setup * [Caching Guide](/guide/caching) - Core caching concepts and patterns * [Serialization](/guide/serialization) - Serializer configuration and performance --- --- url: /guide/implementations/in-memory.md --- # In-Memory Implementations Foundatio provides in-memory implementations for all core abstractions. These are perfect for development, testing, and single-process applications. In L1/L2 caching terminology, these serve as L1 (local) caches. ## Overview | Implementation | Interface | Package | |----------------|-----------|---------| | `InMemoryCacheClient` | `ICacheClient` | Foundatio | | `InMemoryQueue` | `IQueue` | Foundatio | | `InMemoryMessageBus` | `IMessageBus` | Foundatio | | `InMemoryFileStorage` | `IFileStorage` | Foundatio | | `CacheLockProvider` | `ILockProvider` | Foundatio | ## Installation In-memory implementations are included in the core Foundatio package: ```bash dotnet add package Foundatio ``` ## InMemoryCacheClient A high-performance in-memory cache (L1) with optional LRU eviction and memory-based limits. When used with `HybridCacheClient`, this serves as the L1 (local) tier in an L1/L2 caching architecture. ### Basic Usage ```csharp using Foundatio.Caching; var cache = new InMemoryCacheClient(); // Store and retrieve values await cache.SetAsync("key", "value"); var value = await cache.GetAsync("key"); // With expiration await cache.SetAsync("temp", "data", TimeSpan.FromMinutes(5)); ``` ### Configuration Options ```csharp var cache = new InMemoryCacheClient(options => { // Maximum items (enables LRU eviction) options.MaxItems = 1000; // Clone values on get/set (prevents reference sharing bugs) options.CloneValues = true; // Logger factory options.LoggerFactory = loggerFactory; // Time provider (useful for testing) options.TimeProvider = TimeProvider.System; }); ``` ### Value Cloning The `CloneValues` option controls whether cached values are cloned on read and write operations to prevent reference sharing bugs. See [Caching Guide - Value Cloning](/guide/caching#clonevalues) for complete documentation including the problem, solution, performance trade-offs, and best practices. ### Features * **LRU Eviction**: Automatically removes least recently used items when `MaxItems` is reached * **Memory-Based Eviction**: Limit cache by memory consumption with `WithDynamicSizing()` or `WithFixedSizing()` * **Expiration**: Items can have absolute or sliding expiration * **Value Cloning**: Optionally clone values to prevent reference sharing issues * **Thread-Safe**: All operations are thread-safe ### Memory-Based Eviction Limit cache size by memory consumption with intelligent size-aware eviction. When the cache exceeds the memory limit, it evicts items based on a combination of size, age, and access recency. ```csharp // Dynamic sizing: Automatically calculates entry sizes (recommended for mixed object types) var cache = new InMemoryCacheClient(o => o .WithDynamicSizing(maxMemorySize: 100 * 1024 * 1024) // 100 MB limit .MaxItems(10000)); // Optional: also limit by item count // Fixed sizing: Maximum performance when entries are uniform size var fixedSizeCache = new InMemoryCacheClient(o => o .WithFixedSizing( maxMemorySize: 50 * 1024 * 1024, // 50 MB limit averageEntrySize: 1024)); // Assume 1KB per entry // Check current memory usage Console.WriteLine($"Memory: {cache.CurrentMemorySize:N0} / {cache.MaxMemorySize:N0} bytes"); ``` **How dynamic sizing works:** * Uses fast paths for common types (strings, primitives, arrays) * Falls back to JSON serialization for complex objects * Caches type size calculations for performance ### Per-Entry Size Limits Prevent individual large entries from consuming too much cache space: ```csharp // Skip oversized entries (default behavior) var cache = new InMemoryCacheClient(o => o .WithDynamicSizing(100 * 1024 * 1024) // 100 MB total .MaxEntrySize(1 * 1024 * 1024)); // 1 MB per entry limit // Entries exceeding MaxEntrySize are skipped (not cached) and a warning is logged var result = await cache.SetAsync("large-data", veryLargeObject); // result = false if entry exceeds MaxEntrySize // Strict mode: Throw exception on oversized entries var strictCache = new InMemoryCacheClient(o => o .WithDynamicSizing(100 * 1024 * 1024) .MaxEntrySize(1 * 1024 * 1024) .ShouldThrowOnMaxEntrySizeExceeded()); // Throws MaxEntrySizeExceededCacheException try { await strictCache.SetAsync("large-data", veryLargeObject); } catch (MaxEntrySizeExceededCacheException ex) { // Handle oversized entry _logger.LogError(ex, "Entry too large for cache: {EntrySize} > {MaxEntrySize}", ex.EntrySize, ex.MaxEntrySize); } ``` **When to use MaxEntrySize:** * **API response caching**: Prevent a single large response from evicting many smaller cached items * **User data caching**: Limit impact of users with unusually large data * **Memory protection**: Guard against unbounded object growth ### DI Registration ```csharp // Simple registration services.AddSingleton(); // With configuration services.AddSingleton(sp => new InMemoryCacheClient(options => { options.MaxItems = 1000; options.LoggerFactory = sp.GetRequiredService(); })); ``` ## InMemoryQueue A thread-safe in-memory queue with retry support and dead letter handling. ### Basic Usage ```csharp using Foundatio.Queues; var queue = new InMemoryQueue(); // Enqueue items await queue.EnqueueAsync(new WorkItem { Id = 1, Data = "Hello" }); // Dequeue and process var entry = await queue.DequeueAsync(); if (entry != null) { // Process the item Console.WriteLine(entry.Value.Data); // Mark as complete await entry.CompleteAsync(); } ``` ### Configuration Options ```csharp var queue = new InMemoryQueue(options => { // Queue identifier options.Name = "work-items"; // Work item timeout (for retry) options.WorkItemTimeout = TimeSpan.FromMinutes(5); // Retry settings options.Retries = 3; options.RetryDelay = TimeSpan.FromSeconds(30); // Processing behaviors options.Behaviors.Add(new DuplicateDetectionQueueBehavior(cacheClient, loggerFactory)); // Logger options.LoggerFactory = loggerFactory; }); ``` ### Processing Patterns ```csharp // Continuous processing with handler await queue.StartWorkingAsync(async (entry, token) => { await ProcessWorkItemAsync(entry.Value); }); // Process until empty while (await queue.GetQueueStatsAsync() is { Queued: > 0 }) { var entry = await queue.DequeueAsync(); if (entry is null) break; await entry.CompleteAsync(); } ``` ### DI Registration ```csharp services.AddSingleton>(sp => new InMemoryQueue(options => { options.Name = "work-items"; options.WorkItemTimeout = TimeSpan.FromMinutes(5); options.LoggerFactory = sp.GetRequiredService(); })); ``` ## InMemoryMessageBus A simple in-memory pub/sub message bus for single-process communication. ### Basic Usage ```csharp using Foundatio.Messaging; var messageBus = new InMemoryMessageBus(); // Subscribe to messages await messageBus.SubscribeAsync(message => { Console.WriteLine($"User created: {message.UserId}"); }); // Publish messages await messageBus.PublishAsync(new UserCreatedEvent { UserId = "123" }); ``` ### Configuration Options ```csharp var messageBus = new InMemoryMessageBus(options => { options.LoggerFactory = loggerFactory; options.Serializer = serializer; }); ``` ### Subscription Management ```csharp // Subscribe with options await messageBus.SubscribeAsync( handler: async (message, token) => { await ProcessOrderAsync(message); }, cancellationToken: stoppingToken); // Type hierarchy subscription await messageBus.SubscribeAsync(message => { // Receives all events that inherit from BaseEvent }); ``` ### DI Registration ```csharp services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); ``` ## InMemoryFileStorage An in-memory file storage implementation with optional size limits. ### Basic Usage ```csharp using Foundatio.Storage; var storage = new InMemoryFileStorage(); // Save files await storage.SaveFileAsync("documents/file.txt", "Hello, World!"); // Read files var content = await storage.GetFileContentsAsync("documents/file.txt"); // List files var files = await storage.GetFileListAsync("documents/"); ``` ### Configuration Options ```csharp var storage = new InMemoryFileStorage(options => { // Maximum number of files options.MaxFiles = 1000; // Maximum file size options.MaxFileSize = 100 * 1024 * 1024; // 100MB // Logger options.LoggerFactory = loggerFactory; // Serializer for metadata options.Serializer = serializer; }); ``` ### File Operations ```csharp // Save from stream using var stream = File.OpenRead("local-file.txt"); await storage.SaveFileAsync("remote/file.txt", stream); // Get file info var spec = await storage.GetFileInfoAsync("remote/file.txt"); Console.WriteLine($"Size: {spec?.Size}, Modified: {spec?.Modified}"); // Check existence if (await storage.ExistsAsync("remote/file.txt")) { // File exists } // Delete files await storage.DeleteFileAsync("remote/file.txt"); await storage.DeleteFilesAsync("temp/"); // Delete by pattern ``` ### DI Registration ```csharp services.AddSingleton(sp => new InMemoryFileStorage(options => { options.MaxFiles = 1000; options.LoggerFactory = sp.GetRequiredService(); })); ``` ## CacheLockProvider (In-Memory Locks) Use `CacheLockProvider` with `InMemoryCacheClient` for in-memory distributed locks. ### Basic Usage ```csharp using Foundatio.Lock; var cache = new InMemoryCacheClient(); var messageBus = new InMemoryMessageBus(); var locker = new CacheLockProvider(cache, messageBus); // Acquire a lock await using var lockHandle = await locker.AcquireAsync("resource-key"); if (lockHandle != null) { // Do exclusive work } ``` ### Configuration Options ```csharp var locker = new CacheLockProvider(cache, messageBus, options => { // Default lock duration options.DefaultTimeToLive = TimeSpan.FromMinutes(5); // Logger options.LoggerFactory = loggerFactory; }); ``` ### DI Registration ```csharp services.AddSingleton(sp => new CacheLockProvider( sp.GetRequiredService(), sp.GetRequiredService())); ``` ## Complete In-Memory Setup ### All Services ```csharp public static IServiceCollection AddFoundatioInMemory( this IServiceCollection services) { // Cache services.AddSingleton(); // Message Bus services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); // Lock Provider services.AddSingleton(sp => new CacheLockProvider( sp.GetRequiredService(), sp.GetRequiredService())); // File Storage services.AddSingleton(); return services; } // With queues public static IServiceCollection AddFoundatioQueue( this IServiceCollection services, string name) where T : class { services.AddSingleton>(sp => new InMemoryQueue(options => { options.Name = name; options.LoggerFactory = sp.GetRequiredService(); })); return services; } ``` ### Usage ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddFoundatioInMemory(); builder.Services.AddFoundatioQueue("work-items"); builder.Services.AddFoundatioQueue("emails"); var app = builder.Build(); ``` ## When to Use In-Memory ### ✅ Good Use Cases * **Local Development**: No external dependencies needed * **Unit Testing**: Fast, isolated tests * **Single-Process Apps**: Simple deployments * **Prototyping**: Quick iterations * **Small Workloads**: Low-traffic applications ### ⚠️ Limitations * **No Persistence**: Data lost on restart * **Single Process**: No cross-process communication * **Memory Bound**: Limited by available RAM * **No Clustering**: Not suitable for distributed systems ### Switching to Production ```csharp // Easy to swap implementations if (builder.Environment.IsDevelopment()) { services.AddSingleton(); } else { services.AddSingleton(sp => new RedisCacheClient(options => { options.ConnectionMultiplexer = ConnectionMultiplexer.Connect("redis:6379"); })); } ``` ## Testing with In-Memory ```csharp public class CacheTests { [Fact] public async Task ShouldCacheAndRetrieveValue() { // Arrange var cache = new InMemoryCacheClient(); // Act await cache.SetAsync("key", "value"); var result = await cache.GetAsync("key"); // Assert Assert.Equal("value", result); } } ``` ## Next Steps * [Redis Implementation](./redis) - Production-ready distributed caching * [Azure Implementation](./azure) - Cloud-native Azure services * [AWS Implementation](./aws) - Amazon Web Services integration --- --- url: /guide/jobs.md --- # Jobs Jobs allow you to run long-running processes without worrying about them being terminated prematurely. Foundatio provides several base classes that handle the boilerplate of continuous execution, cancellation, locking, queue processing, and hosting integration — so you focus on your business logic. ## The IJob Interface [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Jobs/IJob.cs) Every job implements a single method: ```csharp public interface IJob { Task RunAsync(CancellationToken cancellationToken = default); } ``` You can implement `IJob` directly, but in practice you'll derive from one of the base classes below. ## Choosing a Job Type | Scenario | Base Class | When to Use | |----------|-----------|-------------| | Scheduled or periodic work | `JobBase` | Maintenance tasks, report generation, data sync | | Singleton / leader-elected work | `JobWithLockBase` | Only one instance should run across all servers | | Processing queue items | `QueueJobBase` | Each unit of work arrives as a queue message | | On-demand heterogeneous tasks | `WorkItemJob` + handlers | User-triggered operations, bulk operations with progress | ### Architectural Tradeoffs **`JobBase` vs `QueueJobBase`:** A `JobBase` that polls a database on an interval is simpler to reason about but wastes cycles when there's no work. A `QueueJobBase` reacts instantly to new messages and naturally distributes load across instances, but adds a queue dependency. Use `QueueJobBase` when work arrives unpredictably and latency matters; use `JobBase` when work is periodic or the polling interval is acceptable. **`QueueJobBase` vs `WorkItemJob`:** `QueueJobBase` creates one strongly-typed queue per job — ideal when you have a steady stream of homogeneous work (order processing, email sending, image resizing). `WorkItemJob` uses a single shared `IQueue` to multiplex many task types through one queue and job pool. Prefer `WorkItemJob` when tasks are sporadic, one-off, or varied (user-triggered deletes, bulk exports, cache rebuilds) — it avoids creating a dedicated queue and job class for each operation. `WorkItemJob` also supports built-in progress reporting, making it natural for operations that a user is waiting on. **Lock timeouts and self-healing:** Locks acquired via `JobWithLockBase` or `ILockProvider.AcquireAsync` have a `timeUntilExpires` parameter (default: 20 minutes). If a server crashes while holding a lock, the lock *automatically releases* after this timeout — no manual intervention needed. Set `timeUntilExpires` to a duration comfortably longer than your expected job duration so the lock doesn't expire mid-run, but short enough that a crash doesn't block the next run for too long. For jobs where you can measure average duration, set the timeout to roughly 2-3x that average. For long or unpredictable jobs, use a shorter timeout and call `context.RenewLockAsync()` periodically to extend the lease. When acquiring a lock in `GetLockAsync`, pass `new CancellationToken(true)` to make the attempt non-blocking — `AcquireAsync` checks `cancellationToken.IsCancellationRequested` to decide whether to wait; an already-cancelled token means "try once and return `null` if the lock is held." This lets interval-based jobs gracefully skip a run rather than pile up waiting for a busy lock. The queue's `WorkItemTimeout` serves the same self-healing purpose for queue entries: entries that aren't completed or renewed within the timeout are redelivered to another consumer. ## Standard Jobs ### JobBase [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Jobs/JobBase.cs) `JobBase` provides structured logging (`_logger`), a `TimeProvider`, and a `ResiliencePolicyProvider`. All base classes accept optional `TimeProvider` and `IResiliencePolicyProvider` constructor parameters (defaulting to `TimeProvider.System` and `DefaultResiliencePolicyProvider.Instance`). You override `RunInternalAsync` and receive a `JobContext`: ```csharp using Foundatio.Jobs; public class CleanupJob : JobBase { public CleanupJob( TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory) : base(timeProvider, resiliencePolicyProvider, loggerFactory) { } protected override async Task RunInternalAsync(JobContext context) { var deletedCount = await CleanupOldRecordsAsync(context.CancellationToken); _logger.LogInformation("Cleaned up {Count} records", deletedCount); return JobResult.Success; } } ``` ### JobContext `JobContext` is passed to `RunInternalAsync` and carries everything your job needs at runtime: | Member | Description | |--------|-------------| | `CancellationToken` | Signals that the job should stop gracefully | | `Lock` | The distributed lock held by the job (`null` unless using `JobWithLockBase`) | | `RenewLockAsync()` | Extends the lock lease — call this in long-running loops to prevent expiration. In `QueueEntryContext`, also renews the queue entry's visibility timeout so the message isn't redelivered to another consumer. | ```csharp protected override async Task RunInternalAsync(JobContext context) { foreach (var batch in GetBatches()) { context.CancellationToken.ThrowIfCancellationRequested(); await ProcessBatchAsync(batch); await context.RenewLockAsync(); // keep the lock alive between batches } return JobResult.Success; } ``` ### JobWithLockBase [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Jobs/JobWithLockBase.cs) `JobWithLockBase` automatically acquires a distributed lock before each run and releases it afterward. If the lock cannot be acquired, the run is cancelled — your code is never called. This makes it ideal for leader-election scenarios where exactly one instance should execute across a cluster. Override two methods: * **`GetLockAsync`** — return the lock to acquire, or `null` to skip the run. * **`RunInternalAsync`** — your job logic, called only while the lock is held. ```csharp using Foundatio.Jobs; using Foundatio.Lock; [Job(Description = "Singleton maintenance job", Interval = "5s")] public class MaintenanceJob : JobWithLockBase { private readonly ILockProvider _lockProvider; public MaintenanceJob( ICacheClient cache, IMessageBus messageBus, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory) : base(timeProvider, resiliencePolicyProvider, loggerFactory) { _lockProvider = new CacheLockProvider(cache, messageBus, loggerFactory); } protected override Task GetLockAsync(CancellationToken cancellationToken) { // Pass an already-cancelled token so AcquireAsync attempts the lock // exactly once without waiting. If the lock is held by another instance, // it returns null immediately and this run is skipped. return _lockProvider.AcquireAsync( nameof(MaintenanceJob), timeUntilExpires: TimeSpan.FromMinutes(15), cancellationToken: new CancellationToken(true)); } protected override async Task RunInternalAsync(JobContext context) { _logger.LogInformation("Running maintenance (lock held)..."); await DoMaintenanceAsync(context.CancellationToken); return JobResult.Success; } } ``` > **Why `new CancellationToken(true)`?** `ILockProvider.AcquireAsync` uses the cancellation token to decide whether to wait for a busy lock. A token that is already cancelled tells the provider "try once — if the lock is held, return `null` immediately." This is the standard pattern for jobs that run on an interval and should simply skip the current iteration if another instance is already running. **`JobWithLockBase` vs manual locking in `JobBase`:** * Use **`JobWithLockBase`** when the *entire run* must be single-instance. The lock wraps the full execution and is released automatically — even on exceptions. Set `timeUntilExpires` in `GetLockAsync` to at least 2-3x your expected run duration so the lock self-heals after a crash but doesn't expire during normal operation. * Use **manual `ILockProvider.AcquireAsync`** inside `JobBase` when you need finer-grained control — for example, locking individual resources while allowing the job itself to run on multiple servers: ```csharp public class ResourceSyncJob : JobBase { private readonly ILockProvider _locker; private readonly IResourceRepository _repository; public ResourceSyncJob( ILockProvider locker, IResourceRepository repository, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory) : base(timeProvider, resiliencePolicyProvider, loggerFactory) { _locker = locker; _repository = repository; } protected override async Task RunInternalAsync(JobContext context) { var pendingResources = await _repository.GetPendingSyncAsync(context.CancellationToken); if (pendingResources.Count == 0) return JobResult.Success; _logger.LogInformation("Found {Count} resources to sync", pendingResources.Count); foreach (var resource in pendingResources) { context.CancellationToken.ThrowIfCancellationRequested(); await using var lck = await _locker.AcquireAsync( $"resource-sync:{resource.Id}", cancellationToken: new CancellationToken(true)); if (lck is null) { _logger.LogDebug("Skipping resource {ResourceId}, another instance is syncing it", resource.Id); continue; } await _repository.SyncAsync(resource, context.CancellationToken); } return JobResult.Success; } } ``` ### IJobWithOptions `IJobWithOptions` extends `IJob` with a `JobOptions` property. `JobWithLockBase` implements this interface, and `JobRunner` uses it to pass runtime configuration (name, interval, iteration limit) to job instances. You rarely need to implement it directly. ```csharp public interface IJobWithOptions : IJob { JobOptions? Options { get; set; } } ``` ### Running Jobs ```csharp var job = serviceProvider.GetRequiredService(); // Run once await job.RunAsync(); // Run continuously with a 5-minute pause between iterations await job.RunContinuousAsync( interval: TimeSpan.FromMinutes(5), cancellationToken: stoppingToken); // Run exactly 100 iterations then stop await job.RunContinuousAsync( iterationLimit: 100, cancellationToken: stoppingToken); ``` `RunContinuousAsync` handles the loop, error delays, and cancellation for you. For queue-based jobs, the return value is the number of items processed successfully; for standard jobs, it's the iteration count. ### Job Results `JobResult` communicates the outcome of each run to the framework. When running continuously, a failed result triggers an automatic delay before the next iteration to avoid tight error loops: ```csharp protected override Task RunInternalAsync(JobContext context) { try { // Success return Task.FromResult(JobResult.Success); // Success with message return Task.FromResult(JobResult.SuccessWithMessage("Processed 100 items")); // Failed with message return Task.FromResult(JobResult.FailedWithMessage("Database connection failed")); // Cancelled return Task.FromResult(JobResult.Cancelled); } catch (Exception ex) { // From exception return Task.FromResult(JobResult.FromException(ex)); } } ``` | Factory | `IsSuccess` | Behavior in continuous mode | |---------|------------|---------------------------| | `Success` / `SuccessWithMessage` | `true` | Waits `Interval` then runs again | | `FailedWithMessage` / `FromException` | `false` | Waits at least 100ms (or `Interval`, whichever is longer) | | `Cancelled` / `CancelledWithMessage` | N/A | Logged as warning; loop continues | ## Queue Processor Jobs ### QueueJobBase\ [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Jobs/QueueJobBase.cs) `QueueJobBase` processes items from an `IQueue`. Each call to `RunAsync` dequeues one item and calls your `ProcessQueueEntryAsync` method. It handles dequeue timeouts, cancellation, poison messages (null values), and optional per-entry locking automatically. **Key behaviors:** * **AutoComplete (default: `true`)** — entries are completed when `ProcessQueueEntryAsync` returns success, or abandoned on failure/exception. Set `AutoComplete = false` when you need to call `CompleteAsync()` / `AbandonAsync()` yourself. * **Entry-level locking** — override `GetQueueEntryLockAsync` to acquire a distributed lock per queue entry before processing. The default returns an empty (no-op) lock. If `GetQueueEntryLockAsync` returns `null`, the entry is abandoned. If it throws, the entry is abandoned and a failure `JobResult` is returned. * **Poison message safety** — entries with `null` values (deserialization failures) are automatically abandoned without calling your code. ```csharp using Foundatio.Jobs; using Foundatio.Queues; public class OrderProcessorJob : QueueJobBase { private readonly IOrderService _orderService; public OrderProcessorJob( IQueue queue, IOrderService orderService, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory) : base(queue, timeProvider, resiliencePolicyProvider, loggerFactory) { _orderService = orderService; } protected override async Task ProcessQueueEntryAsync( QueueEntryContext context) { var workItem = context.QueueEntry.Value; _logger.LogInformation("Processing order {OrderId}", workItem.OrderId); try { await _orderService.ProcessAsync(workItem.OrderId, context.CancellationToken); return JobResult.Success; } catch (Exception ex) { _logger.LogError(ex, "Failed to process order {OrderId}", workItem.OrderId); return JobResult.FromException(ex); } } } public record OrderWorkItem { public int OrderId { get; init; } } ``` ### QueueEntryContext\ `QueueEntryContext` extends `JobContext` and is passed to `ProcessQueueEntryAsync`: | Member | Description | |--------|-------------| | `QueueEntry` | The `IQueueEntry` — access `Value`, `Id`, `Attempts`, `CompleteAsync()`, `AbandonAsync()` | | `CancellationToken` | Inherited from `JobContext` | | `Lock` | The per-entry lock from `GetQueueEntryLockAsync` | | `RenewLockAsync()` | Renews the queue entry's visibility timeout (preventing redelivery) *and* the per-entry distributed lock | ### IQueueJob\ `IQueueJob` extends `IJob` and exposes the queue and a direct processing method: * **`ProcessAsync(IQueueEntry, CancellationToken)`** — process a single entry obtained externally (e.g., from a test or a different dequeue source). * **`Queue`** — the underlying `IQueue`. ### Running Queue Jobs ```csharp var queue = new InMemoryQueue(); var job = serviceProvider.GetRequiredService(); // Enqueue work await queue.EnqueueAsync(new OrderWorkItem { OrderId = 123 }); await queue.EnqueueAsync(new OrderWorkItem { OrderId = 456 }); // Process all queued items, then stop (waits up to 30s for an empty queue) await job.RunUntilEmptyAsync(); // Process with an explicit timeout for the empty-queue wait await job.RunUntilEmptyAsync(TimeSpan.FromSeconds(10)); // Run continuously — processes items as they arrive await job.RunContinuousAsync(cancellationToken: stoppingToken); ``` ### Queue Processing Behaviors Behaviors hook into queue lifecycle events to add cross-cutting concerns without modifying your job. Attach them when creating the queue: ```csharp var cache = new InMemoryCacheClient(); var queue = new InMemoryQueue(o => o .AddBehavior(new DuplicateDetectionQueueBehavior( cache, loggerFactory, detectionWindow: TimeSpan.FromMinutes(10)))); ``` `DuplicateDetectionQueueBehavior` discards duplicate entries based on `IHaveUniqueIdentifier.UniqueIdentifier`. Implement the interface on your work item type: ```csharp public record OrderWorkItem : IHaveUniqueIdentifier { public int OrderId { get; init; } public string? UniqueIdentifier => $"order:{OrderId}"; } ``` You can create custom behaviors by extending `QueueBehaviorBase` and overriding any combination of `OnEnqueuing`, `OnEnqueued`, `OnDequeued`, `OnCompleted`, `OnAbandoned`, `OnLockRenewed`, and `OnQueueDeleted`. ## Work Item Jobs Work item jobs solve a different problem than queue jobs: they process **heterogeneous** tasks from a single shared queue. A `WorkItemJob` dequeues `WorkItemData` messages and dispatches each one to a type-specific handler. This is ideal for user-triggered operations (bulk deletes, imports, exports) where you want progress reporting and don't want to create a separate queue per task type. ### Define a Work Item Handler Create handlers by extending `WorkItemHandlerBase`: ```csharp using Foundatio.Jobs; public class DeleteEntityWorkItemHandler : WorkItemHandlerBase { private readonly IEntityService _entityService; public DeleteEntityWorkItemHandler( IEntityService entityService, ILogger logger) : base(logger) { _entityService = entityService; } public override async Task HandleItemAsync(WorkItemContext ctx) { var workItem = ctx.GetData(); await ctx.ReportProgressAsync(0, "Starting deletion..."); // Delete children with progress reporting var children = await _entityService.GetChildrenAsync(workItem.EntityId); var total = children.Count; var current = 0; foreach (var child in children) { await _entityService.DeleteAsync(child.Id); current++; await ctx.ReportProgressAsync( (current * 100) / total, $"Deleted {current} of {total} children"); } await _entityService.DeleteAsync(workItem.EntityId); await ctx.ReportProgressAsync(100, "Deletion complete"); } } public record DeleteEntityWorkItem { public int EntityId { get; init; } } ``` ### WorkItemContext `WorkItemContext` is passed to `HandleItemAsync` and provides everything a handler needs: | Member | Description | |--------|-------------| | `GetData()` | Deserializes the raw payload to your work item type | | `Data` | The raw work item payload (use `GetData()` instead) | | `JobId` | Unique identifier for this job run | | `WorkItemLock` | Optional distributed lock for the work item | | `CancellationToken` | Signals that processing should stop | | `Result` | Set to `JobResult.FailedWithMessage(...)` to indicate failure without throwing | | `ReportProgressAsync(progress, message)` | Publishes `WorkItemStatus` updates via `IMessageBus` | | `RenewLockAsync()` | Extends the work item lock lease | ### WorkItemHandlers `WorkItemHandlers` is a registry mapping work item data types to their handlers. You can register handlers in several ways: ```csharp var handlers = new WorkItemHandlers(); // Instance registration handlers.Register( new DeleteEntityWorkItemHandler(entityService, logger)); // Factory registration (lazy — creates a new handler per invocation) handlers.Register( () => sp.GetRequiredService()); // Inline delegate (for simple tasks that don't need a full handler class) handlers.Register(async ctx => { var data = ctx.GetData(); await ProcessAsync(data); }); ``` ### Register and Run Work Item Jobs ```csharp // DI registration services.AddSingleton>(sp => new InMemoryQueue()); services.AddSingleton(sp => new InMemoryMessageBus()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddScoped(); services.AddSingleton(sp => { var handlers = new WorkItemHandlers(); handlers.Register( () => sp.GetRequiredService()); return handlers; }); // Run with multiple instances for parallel processing var job = serviceProvider.GetRequiredService(); await new JobRunner(job, serviceProvider, instanceCount: 2).RunAsync(stoppingToken); ``` ### Trigger Work Items Use the `EnqueueAsync` extension method to enqueue strongly-typed work items: ```csharp var queue = serviceProvider.GetRequiredService>(); // Enqueue a work item (returns a job ID for tracking) string jobId = await queue.EnqueueAsync(new DeleteEntityWorkItem { EntityId = 123 }); // With progress reporting enabled string jobId = await queue.EnqueueAsync( new DeleteEntityWorkItem { EntityId = 123 }, includeProgressReporting: true); // Subscribe to progress updates var messageBus = serviceProvider.GetRequiredService(); await messageBus.SubscribeAsync(status => { Console.WriteLine($"[{status.WorkItemId}] {status.Progress}% - {status.Message}"); }); ``` ## Job Runner [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Jobs/JobRunner.cs) `JobRunner` orchestrates job execution with support for continuous running, multiple parallel instances, initial delays, and console hosting: ```csharp using Foundatio.Jobs; var job = serviceProvider.GetRequiredService(); var runner = new JobRunner(job, serviceProvider); // Run until cancelled await runner.RunAsync(stoppingToken); // Run in background (fire-and-forget) runner.RunInBackground(); // Multiple parallel instances var multiRunner = new JobRunner(job, serviceProvider, instanceCount: 4); await multiRunner.RunAsync(stoppingToken); ``` ### Console App Hosting `RunInConsoleAsync` sets up `Ctrl+C` and Azure WebJobs shutdown file handling, runs the job, and returns a process exit code: ```csharp var exitCode = await new JobRunner(job, serviceProvider).RunInConsoleAsync(); Environment.Exit(exitCode); // Returns: 0 = success, -1 = failure, 1 = unhandled exception ``` ## Job Options ### Job Attribute Configure job behavior declaratively with the `[Job]` attribute. These values become the defaults that `JobRunner` and the hosting infrastructure use: ```csharp [Job( Name = "MyJob", Description = "Processes pending items", Interval = "5m", InitialDelay = "10s", IsContinuous = true, IterationLimit = -1, InstanceCount = 1 )] public class MyJob : JobBase { public MyJob( TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory) : base(timeProvider, resiliencePolicyProvider, loggerFactory) { } protected override Task RunInternalAsync(JobContext context) { return Task.FromResult(JobResult.Success); } } ``` | Property | Type | Default | Description | |----------|------|---------|-------------| | `Name` | `string?` | Type name minus "Job" suffix | Display name used in logging and status APIs | | `Description` | `string?` | `null` | Human-readable description | | `IsContinuous` | `bool` | `true` | Whether the job runs in a loop | | `Interval` | `string?` | `null` | Delay between iterations (e.g., `"5m"`, `"30s"`) | | `InitialDelay` | `string?` | `null` | Delay before first execution | | `IterationLimit` | `int` | `-1` | Maximum iterations (`-1` = unlimited) | | `InstanceCount` | `int` | `1` | Number of parallel instances | ### JobOptions Class `JobOptions` holds the same settings programmatically. Values from `[Job]` are applied as defaults, and can be overridden at runtime: ```csharp var options = new JobOptions { Name = "CleanupJob", Interval = TimeSpan.FromHours(1), IterationLimit = 100, RunContinuous = true, InstanceCount = 2, InitialDelay = TimeSpan.FromSeconds(30) }; await job.RunContinuousAsync(options, stoppingToken); ``` ## Hosted Service Integration `Foundatio.Extensions.Hosting` integrates Foundatio jobs with ASP.NET Core's `IHostedService` pipeline. Jobs are registered as managed background services that start with the host and shut down gracefully. ### Installation ```bash dotnet add package Foundatio.Extensions.Hosting ``` ### AddJob Extension Register jobs as hosted services with a fluent builder: ```csharp using Foundatio.Extensions.Hosting.Jobs; // Simple registration — runs continuously services.AddJob(); // With configuration services.AddJob(o => o .Interval(TimeSpan.FromHours(1)) .WaitForStartupActions() .InitialDelay(TimeSpan.FromSeconds(30))); // Parallel queue processing services.AddJob(o => o.InstanceCount(4)); ``` The builder exposes: `Name`, `Description`, `JobFactory`, `RunContinuous`, `Interval`, `InitialDelay`, `IterationLimit`, `InstanceCount`, and `WaitForStartupActions`. ### Cron Job Scheduling Schedule jobs using cron expressions: ```csharp using Foundatio.Extensions.Hosting.Jobs; // Every 6 hours services.AddCronJob("0 */6 * * *"); // Every Monday at midnight services.AddCronJob("0 0 * * MON"); // With configuration services.AddCronJob("0 2 * * *", o => o .Name("nightly-maintenance") .WaitForStartupActions()); // Inline action — no job class needed services.AddCronJob("health-check", "*/5 * * * *", async (sp, ct) => { var healthService = sp.GetRequiredService(); await healthService.CheckAsync(ct); }); ``` #### Cron Helper Class Use the `Cron` helper to generate common cron expressions without memorizing the syntax: ```csharp using Foundatio.Extensions.Hosting.Jobs; services.AddCronJob(Cron.Hourly()); // every hour at :00 services.AddCronJob(Cron.Daily(hour: 2)); // daily at 2:00 AM services.AddCronJob(Cron.Weekly(DayOfWeek.Monday, hour: 9)); // Monday at 9 AM services.AddCronJob(Cron.Monthly(day: 1)); // 1st of each month services.AddCronJob(Cron.Minutely(5)); // every 5 minutes services.AddCronJob(Cron.Yearly(month: 1)); // January 1st services.AddCronJob(Cron.Never()); // never (disabled) ``` #### Scheduled Job Options Cron jobs support additional configuration through `ScheduledJobOptionsBuilder`: ```csharp services.AddCronJob("0 0 * * *", o => o .Name("daily-report") .Description("Generates the daily summary report") .WaitForStartupActions() .CronTimeZone("America/New_York") .Enabled(true)); ``` ### Distributed Cron Jobs Ensure only one instance runs a scheduled job across all servers. This requires an `ICacheClient` registration for distributed lock coordination: ```csharp using Foundatio.Extensions.Hosting.Jobs; services.AddDistributedCronJob("0 0 * * *"); // Requires ICacheClient for distributed locking services.AddSingleton(sp => new RedisCacheClient(...)); ``` ### Job Manager `IJobManager` provides a runtime API for inspecting, triggering, and managing scheduled jobs. It is automatically registered when you use `AddCronJob` or `AddJobScheduler`: ```csharp var jobManager = serviceProvider.GetRequiredService(); // View all job statuses JobStatus[] statuses = jobManager.GetJobStatus(); foreach (var status in statuses) Console.WriteLine($"{status.Name}: NextRun={status.NextRun}, LastRun={status.LastRun}"); // Trigger a job on-demand (runs immediately regardless of schedule) await jobManager.RunJobAsync(); // Add or update a scheduled job at runtime jobManager.AddOrUpdate(o => o.CronSchedule(Cron.Hourly())); // Disable a job without removing it jobManager.Update(o => o.Disabled()); // Remove a job entirely jobManager.Remove(); // Release a stuck distributed lock (e.g., after a server crash) await jobManager.ReleaseLockAsync("Cleanup"); ``` ### Manual BackgroundService When the `AddJob` extensions don't fit your needs, you can integrate any Foundatio job with `BackgroundService` directly: ```csharp public class CleanupJobHostedService : BackgroundService { private readonly IServiceProvider _services; private readonly ILogger _logger; public CleanupJobHostedService( IServiceProvider services, ILogger logger) { _services = services; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { using var scope = _services.CreateScope(); var job = scope.ServiceProvider.GetRequiredService(); try { await job.RunAsync(stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "Cleanup job failed"); } await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } } } services.AddScoped(); services.AddHostedService(); ``` ## Common Patterns ### Job with Progress Reporting Use `IMessageBus` to publish progress from standard jobs: ```csharp public class ImportJob : JobBase { private readonly IMessageBus _messageBus; public ImportJob( IMessageBus messageBus, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory) : base(timeProvider, resiliencePolicyProvider, loggerFactory) { _messageBus = messageBus; } protected override async Task RunInternalAsync(JobContext context) { var items = await GetItemsToImportAsync(); var total = items.Count; for (int i = 0; i < total; i++) { if (context.CancellationToken.IsCancellationRequested) return JobResult.Cancelled; await ImportItemAsync(items[i]); await _messageBus.PublishAsync(new ImportProgress { ProcessedCount = i + 1, TotalCount = total, PercentComplete = ((i + 1) * 100) / total }); } return JobResult.Success; } } ``` ### Retry vs Permanent Failure Distinguish between transient errors (retry is useful) and permanent errors (retry would loop forever): ```csharp protected override async Task RunInternalAsync(JobContext context) { try { await DoWorkAsync(context.CancellationToken); return JobResult.Success; } catch (TransientException ex) { return JobResult.FailedWithMessage(ex.Message); // framework retries } catch (PermanentException ex) { _logger.LogError(ex, "Permanent failure — not retrying"); return JobResult.Success; // return success to prevent retry loop } } ``` ### Idempotent Jobs Track progress externally so the job can safely resume after a crash: ```csharp protected override async Task RunInternalAsync(JobContext context) { var lastProcessedId = await _state.GetLastProcessedIdAsync(); var items = await _db.GetItemsAfterAsync(lastProcessedId); foreach (var item in items) { context.CancellationToken.ThrowIfCancellationRequested(); await ProcessItemAsync(item); await _state.SetLastProcessedIdAsync(item.Id); } return JobResult.Success; } ``` ## Best Practices 1. **Always propagate cancellation tokens.** Pass `context.CancellationToken` to every async call and check it in loops. This ensures your job shuts down promptly during host shutdown. 2. **Renew locks in long-running jobs.** If your job holds a distributed lock (via `JobWithLockBase` or queue entry locking), call `context.RenewLockAsync()` periodically — especially between batches. Lock expiration mid-run causes correctness issues. 3. **Keep jobs idempotent.** Jobs may be killed at any point (process recycle, deployment, crash). Track progress so they can pick up where they left off rather than re-processing everything. 4. **Log with structured context.** Use `BeginScope` to correlate all log entries for a unit of work: ```csharp using var _ = _logger.BeginScope(s => s.Property("OrderId", workItem.OrderId)); _logger.LogInformation("Processing order..."); // every log inside this scope automatically includes OrderId ``` 5. **Match job type to workload.** Don't force a `QueueJobBase` when a simple `JobBase` with `RunContinuousAsync` suffices. Don't create separate queues for every task type — use `WorkItemJob` for heterogeneous on-demand work. 6. **Use distributed cron for cluster-wide scheduling.** If you have multiple servers running the same host, use `AddDistributedCronJob` to ensure only one server executes the scheduled run. ## Dependency Injection ### Register Standard Jobs ```csharp services.AddScoped(); services.AddScoped(); services.AddSingleton>(sp => new InMemoryQueue()); ``` ### Register Queue Jobs with Parallel Processing ```csharp services.AddSingleton>(sp => new InMemoryQueue()); services.AddJob(o => o.InstanceCount(4)); ``` ## Next Steps * [Queues](./queues) — Queue implementations for job processing * [Locks](./locks) — Distributed locking for singleton jobs * [Resilience](./resilience) — Retry policies for job reliability * [Serialization](./serialization) — Serializer configuration and performance --- --- url: /guide/locks.md --- # Locks Locks ensure a resource is only accessed by one consumer at any given time. Foundatio provides distributed locking implementations through the `ILockProvider` interface. ## The ILockProvider Interface [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Lock/ILockProvider.cs) ```csharp public interface ILockProvider { Task AcquireAsync(string resource, TimeSpan? timeUntilExpires = null, bool releaseOnDispose = true, CancellationToken cancellationToken = default); Task TryAcquireAsync(string resource, TimeSpan? timeUntilExpires = null, bool releaseOnDispose = true, CancellationToken cancellationToken = default); Task IsLockedAsync(string resource); Task ReleaseAsync(string resource, string lockId); Task ReleaseAsync(string resource); Task RenewAsync(string resource, string lockId, TimeSpan? timeUntilExpires = null); } public interface ILock : IAsyncDisposable { Task RenewAsync(TimeSpan? timeUntilExpires = null); Task ReleaseAsync(); string LockId { get; } string Resource { get; } DateTime AcquiredTimeUtc { get; } TimeSpan TimeWaitedForLock { get; } int RenewalCount { get; } } ``` ### Choosing Between `AcquireAsync` and `TryAcquireAsync` `ILockProvider` exposes two acquisition shapes: | API | Returns | Use when | | --- | --- | --- | | `AcquireAsync` | `Task` — throws `LockAcquisitionTimeoutException` on failure | The work cannot safely run without the lock — failure is genuinely exceptional | | `TryAcquireAsync` | `Task` — `null` on failure | Lock unavailability is a normal control-flow outcome (best-effort dedupe, opportunistic work) | Both are interface methods backed by the same acquisition logic — pick whichever one matches the caller's intent. `AcquireAsync` is the safer default: there is no null return to forget about, so the type system makes "ran the work without holding the lock" impossible to write by accident. ## Implementations ### CacheLockProvider Uses a cache client and message bus for distributed locking: [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Lock/CacheLockProvider.cs) ```csharp using Foundatio.Lock; using Foundatio.Caching; using Foundatio.Messaging; var cache = new InMemoryCacheClient(); var messageBus = new InMemoryMessageBus(); var locker = new CacheLockProvider(cache, messageBus); await using var lck = await locker.TryAcquireAsync("my-resource"); if (lck is not null) { // Exclusive access to resource await DoExclusiveWorkAsync(); } ``` With Redis for production: ```csharp var redis = await ConnectionMultiplexer.ConnectAsync("localhost:6379"); var cache = new RedisCacheClient(o => o.ConnectionMultiplexer = redis); var messageBus = new RedisMessageBus(o => o.Subscriber = redis.GetSubscriber()); var locker = new CacheLockProvider(cache, messageBus); ``` ### ThrottlingLockProvider Limits the number of operations within a time period: [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Lock/ThrottlingLockProvider.cs) ```csharp using Foundatio.Lock; var throttledLocker = new ThrottlingLockProvider( cache, maxHits: 10, // Maximum locks allowed period: TimeSpan.FromMinutes(1) // Per time period ); // Only allows 10 operations per minute across all instances var lck = await throttledLocker.TryAcquireAsync("api-rate-limit"); if (lck is not null) { await CallExternalApiAsync(); await lck.ReleaseAsync(); } else { // Rate limited throw new TooManyRequestsException(); } ``` ### ScopedLockProvider Prefixes all lock keys with a scope: [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Lock/ScopedLockProvider.cs) ```csharp using Foundatio.Lock; var baseLock = new CacheLockProvider(cache, messageBus); var tenantLock = new ScopedLockProvider(baseLock, "tenant:abc"); // Lock key becomes: "tenant:abc:resource-1" await using var lck = await tenantLock.TryAcquireAsync("resource-1"); ``` ## Basic Usage ### Acquire and Release ```csharp var locker = new CacheLockProvider(cache, messageBus); // Best-effort acquire — null when held elsewhere var lck = await locker.TryAcquireAsync("my-resource"); if (lck is not null) { try { // Do exclusive work await ProcessAsync(); } finally { // Always release await lck.ReleaseAsync(); } } ``` ### Using Dispose Pattern The recommended pattern uses `await using` for automatic release: ```csharp await using var lck = await locker.TryAcquireAsync("my-resource"); if (lck is not null) { // Lock is automatically released when scope ends await DoExclusiveWorkAsync(); } ``` ### Non-Blocking Acquire Try to acquire without waiting: ```csharp await using var lck = await locker.TryAcquireAsync("my-resource"); if (lck is null) { // Resource is locked by another process return; } // Got the lock await DoWorkAsync(); ``` ### Blocking Acquire with Timeout Wait up to a timeout, throwing on failure: ```csharp try { await using var lck = await locker.AcquireAsync( "my-resource", acquireTimeout: TimeSpan.FromSeconds(30)); await DoWorkAsync(); } catch (LockAcquisitionTimeoutException) { // Lock could not be acquired before the timeout elapsed. _logger.LogWarning("Timed out waiting for lock"); } ``` If the failure is expected control flow, prefer `TryAcquireAsync` so you don't pay for an exception: ```csharp await using var lck = await locker.TryAcquireAsync( "my-resource", acquireTimeout: TimeSpan.FromSeconds(30)); if (lck is null) { _logger.LogWarning("Could not acquire lock"); return; } await DoWorkAsync(); ``` ## Lock Expiration ### Setting Expiration Locks expire automatically to prevent deadlocks: ```csharp // Lock expires after 5 minutes await using var lck = await locker.TryAcquireAsync( "my-resource", timeUntilExpires: TimeSpan.FromMinutes(5) ); ``` ### Renewing Locks For long-running operations, renew the lock: ```csharp await using var lck = await locker.TryAcquireAsync( "my-resource", timeUntilExpires: TimeSpan.FromMinutes(1) ); if (lck is not null) { // Do some work await DoPartOneAsync(); // Renew lock for more time await lck.RenewAsync(TimeSpan.FromMinutes(1)); // Continue work await DoPartTwoAsync(); } ``` ### Automatic Renewal For very long operations, set up automatic renewal: ```csharp await using var lck = await locker.TryAcquireAsync("my-resource"); if (lck is null) return; using var cts = new CancellationTokenSource(); // Start renewal task var renewTask = Task.Run(async () => { while (!cts.Token.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(30), cts.Token); await lck.RenewAsync(TimeSpan.FromMinutes(1)); } }); try { await VeryLongRunningOperationAsync(); } finally { cts.Cancel(); } ``` ## Common Patterns ### Singleton Processing Ensure only one instance processes a resource: ```csharp public async Task ProcessOrderAsync(int orderId) { await using var lck = await _locker.TryAcquireAsync($"order:{orderId}"); if (lck is null) { _logger.LogInformation("Order {OrderId} is being processed elsewhere", orderId); return; } // Only one instance processes this order await DoProcessingAsync(orderId); } ``` ### Leader Election Elect a single instance for a job: ```csharp public async Task RunAsLeaderAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { await using var lck = await _locker.TryAcquireAsync("leader:job-runner"); if (lck is not null) { _logger.LogInformation("This instance is now the leader"); // Keep renewing while leading while (!ct.IsCancellationRequested) { await DoLeaderWorkAsync(); await lck.RenewAsync(); await Task.Delay(TimeSpan.FromSeconds(5), ct); } } else { // Not leader, wait and try again await Task.Delay(TimeSpan.FromSeconds(30), ct); } } } ``` ### Preventing Duplicate Operations ```csharp public async Task CreateOrderAsync(CreateOrderRequest request) { // Prevent duplicate orders for same customer; throw if we can't lock. await using var lck = await _locker.AcquireAsync( $"create-order:{request.CustomerId}", timeUntilExpires: TimeSpan.FromSeconds(30), acquireTimeout: TimeSpan.FromSeconds(5) ); // Check for recent duplicates var recentOrder = await _db.GetRecentOrderAsync(request.CustomerId); if (recentOrder != null && recentOrder.IsSimilar(request)) { throw new DuplicateOrderException(); } return await _db.CreateOrderAsync(request); } ``` ### Using TryUsingAsync Extension Simplified pattern for lock-protected operations: ```csharp var success = await locker.TryUsingAsync( "my-resource", async ct => { await DoExclusiveWorkAsync(ct); }, timeUntilExpires: TimeSpan.FromMinutes(5), cancellationToken ); if (!success) { _logger.LogWarning("Could not acquire lock"); } ``` ## Rate Limiting with ThrottlingLockProvider ### API Rate Limiting ```csharp public class RateLimitedApiClient { private readonly ThrottlingLockProvider _throttler; private readonly HttpClient _client; public RateLimitedApiClient(ICacheClient cache, HttpClient client) { _throttler = new ThrottlingLockProvider( cache, maxHits: 100, // 100 requests period: TimeSpan.FromMinutes(1) // per minute ); _client = client; } public async Task GetAsync(string endpoint) { await using var lck = await _throttler.TryAcquireAsync("external-api"); if (lck is null) { throw new RateLimitExceededException(); } var response = await _client.GetAsync(endpoint); return await response.Content.ReadFromJsonAsync(); } } ``` ### Per-User Rate Limiting ```csharp public async Task ProcessRequest(string userId) { // 10 requests per minute per user await using var lck = await _throttler.TryAcquireAsync($"user:{userId}:api"); if (lck is null) { return StatusCode(429, "Too many requests"); } return Ok(await ProcessAsync()); } ``` ## Dependency Injection ### Basic Registration ```csharp services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => new CacheLockProvider( sp.GetRequiredService(), sp.GetRequiredService() ) ); ``` ### With Redis ```csharp services.AddSingleton( await ConnectionMultiplexer.ConnectAsync("localhost:6379") ); services.AddSingleton(sp => new RedisCacheClient(o => o.ConnectionMultiplexer = sp.GetRequiredService() ) ); services.AddSingleton(sp => new RedisMessageBus(o => o.Subscriber = sp.GetRequiredService().GetSubscriber() ) ); services.AddSingleton(sp => new CacheLockProvider( sp.GetRequiredService(), sp.GetRequiredService() ) ); ``` ### Multiple Lock Providers ```csharp // General-purpose locking services.AddKeyedSingleton("general", (sp, _) => new CacheLockProvider( sp.GetRequiredService(), sp.GetRequiredService() ) ); // Rate limiting services.AddKeyedSingleton("throttle", (sp, _) => new ThrottlingLockProvider( sp.GetRequiredService(), maxHits: 100, period: TimeSpan.FromMinutes(1) ) ); ``` ## Best Practices ### 1. Pick the API That Matches Your Intent If failure to acquire is **normal control flow** (best-effort dedupe, opportunistic work), call `TryAcquireAsync` and check for `null`. If failure is **genuinely exceptional** (the work cannot run safely without the lock), call `AcquireAsync` and let it throw. ```csharp // ✅ Best-effort: skip work when lock is held elsewhere await using var lck = await locker.TryAcquireAsync("resource"); if (lck is null) return; await DoWork(); // ✅ Required lock: throw if we can't get it await using var lck = await locker.AcquireAsync("resource", acquireTimeout: TimeSpan.FromSeconds(5)); await DoWork(); // ↑ If acquisition fails, LockAcquisitionTimeoutException propagates. ``` ::: warning Do not catch `LockAcquisitionTimeoutException` to convert "couldn't acquire" into a normal control-flow path — that is exactly the case `TryAcquireAsync` is designed for. Use the right method up front. ::: ### 2. Common Patterns **Pattern: Skip Processing (best-effort)** ```csharp public async Task TryProcessOrderAsync(int orderId) { await using var lck = await _locker.TryAcquireAsync($"order:{orderId}"); if (lck is null) { _logger.LogDebug("Order {OrderId} is locked, skipping", orderId); return; } await DoProcessingAsync(orderId); } ``` **Pattern: Required Lock (throws)** ```csharp public async Task ProcessOrderAsync(int orderId) { // Throws LockAcquisitionTimeoutException if the lock can't be acquired. await using var lck = await _locker.AcquireAsync( $"order:{orderId}", acquireTimeout: TimeSpan.FromSeconds(30)); await DoProcessingAsync(orderId); } ``` **Pattern: Wait with Cancellation** ```csharp public async Task ProcessOrderAsync(int orderId, CancellationToken ct) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); await using var lck = await _locker.AcquireAsync( $"order:{orderId}", cancellationToken: timeoutCts.Token); await DoProcessingAsync(orderId); } ``` **Pattern: Return Result (best-effort)** ```csharp public async Task> TryWithLockAsync(string resource, Func> work) { await using var lck = await _locker.TryAcquireAsync(resource); if (lck is null) return LockResult.NotAcquired(); var result = await work(); return LockResult.Success(result); } ``` ### 3. Use Meaningful Lock Names ```csharp // ✅ Good: Descriptive, hierarchical await locker.TryAcquireAsync($"order:process:{orderId}"); await locker.TryAcquireAsync($"user:{userId}:balance:update"); // ❌ Bad: Generic, ambiguous await locker.TryAcquireAsync("lock1"); await locker.TryAcquireAsync("resource"); ``` ### 4. Set Appropriate Expiration ```csharp // Match expiration to expected operation duration + buffer await locker.TryAcquireAsync("quick-op", TimeSpan.FromSeconds(30)); // 10s operation + buffer await locker.TryAcquireAsync("long-op", TimeSpan.FromMinutes(10)); // 5min operation + buffer ``` ::: tip Default Expiration If no expiration is specified, `CacheLockProvider` defaults to 20 minutes. Always set an explicit expiration based on your expected operation duration. ::: ### 5. Prefer `await using` Pattern Locks implement `IAsyncDisposable`. Using `await using` ensures the lock is released even if an exception occurs: ```csharp // ✅ Good: Automatic release on dispose await using var lck = await locker.TryAcquireAsync("resource"); if (lck is null) return; await DoWork(); // Lock is automatically released when scope ends // ✅ Good: Manual release when needed var lck = await locker.TryAcquireAsync("resource", releaseOnDispose: false); if (lck is null) return; try { await DoWork(); } finally { await lck.ReleaseAsync(); } // ❌ Bad: Not using dispose pattern var lck = await locker.TryAcquireAsync("resource"); if (lck is null) return; await DoWork(); // Lock may not be released if DoWork throws! ``` ### 6. Use Scoped Locks for Multi-Tenant ```csharp var tenantLock = new ScopedLockProvider(baseLock, $"tenant:{tenantId}"); // All locks are isolated per tenant ``` ## Lock Information Access lock metadata: ```csharp await using var lck = await locker.TryAcquireAsync("resource"); if (lck is not null) { Console.WriteLine($"Lock ID: {lck.LockId}"); Console.WriteLine($"Resource: {lck.Resource}"); Console.WriteLine($"Acquired: {lck.AcquiredTimeUtc}"); Console.WriteLine($"Wait time: {lck.TimeWaitedForLock}"); Console.WriteLine($"Renewals: {lck.RenewalCount}"); } ``` ## Next Steps * [Caching](./caching) - Cache implementations used by locks * [Messaging](./messaging) - Message bus used for lock coordination * [Jobs](./jobs) - Background jobs with distributed locking * [Resilience](./resilience) - Retry policies for lock acquisition * [Serialization](./serialization) - Serializer configuration and performance --- --- url: /guide/messaging.md --- # Messaging Messaging allows you to publish and subscribe to messages flowing through your application using pub/sub patterns. Foundatio provides multiple message bus implementations through the `IMessageBus` interface. ## The IMessageBus Interface [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Messaging/IMessageBus.cs) ```csharp public interface IMessageBus : IMessagePublisher, IMessageSubscriber, IDisposable, IAsyncDisposable { } public interface IMessagePublisher { Task PublishAsync(Type messageType, object message, MessageOptions? options = null, CancellationToken cancellationToken = default); } public interface IMessageSubscriber { Task SubscribeAsync(Func handler, CancellationToken cancellationToken = default) where T : class; } ``` ## Implementations ### InMemoryMessageBus An in-memory message bus for development and testing: [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Messaging/InMemoryMessageBus.cs) ```csharp using Foundatio.Messaging; var messageBus = new InMemoryMessageBus(); // Subscribe to messages await messageBus.SubscribeAsync(async msg => { Console.WriteLine($"Order created: {msg.OrderId}"); }); // Publish a message await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }); ``` ### AzureServiceBusMessageBus Messaging using Azure Service Bus (separate package): [View source](https://github.com/FoundatioFx/Foundatio.AzureServiceBus/blob/main/src/Foundatio.AzureServiceBus/Messaging/AzureServiceBusMessageBus.cs) ```csharp // dotnet add package Foundatio.AzureServiceBus using Foundatio.AzureServiceBus.Messaging; var messageBus = new AzureServiceBusMessageBus(o => { o.ConnectionString = "..."; o.Topic = "events"; }); ``` ### KafkaMessageBus Messaging using Apache Kafka (separate package): [View source](https://github.com/FoundatioFx/Foundatio.Kafka/blob/main/src/Foundatio.Kafka/Messaging/KafkaMessageBus.cs) ```csharp // dotnet add package Foundatio.Kafka using Foundatio.Kafka.Messaging; var messageBus = new KafkaMessageBus(o => { o.BootstrapServers = "localhost:9092"; }); ``` ### RabbitMQMessageBus Messaging using RabbitMQ (separate package): [View source](https://github.com/FoundatioFx/Foundatio.RabbitMQ/blob/main/src/Foundatio.RabbitMQ/Messaging/RabbitMQMessageBus.cs) ```csharp // dotnet add package Foundatio.RabbitMQ using Foundatio.RabbitMQ.Messaging; var messageBus = new RabbitMQMessageBus(o => { o.ConnectionString = "amqp://guest:guest@localhost:5672"; }); ``` ### RedisMessageBus Distributed messaging using Redis pub/sub (separate package): [View source](https://github.com/FoundatioFx/Foundatio.Redis/blob/main/src/Foundatio.Redis/Messaging/RedisMessageBus.cs) ```csharp // dotnet add package Foundatio.Redis using Foundatio.Redis.Messaging; using StackExchange.Redis; var redis = await ConnectionMultiplexer.ConnectAsync("localhost:6379"); var messageBus = new RedisMessageBus(o => o.Subscriber = redis.GetSubscriber()); ``` ### SQSMessageBus Messaging using AWS SNS/SQS (separate package): [View source](https://github.com/FoundatioFx/Foundatio.AWS/blob/master/src/Foundatio.AWS/Messaging/SQSMessageBus.cs) ```csharp // dotnet add package Foundatio.AWS using Foundatio.Messaging; var messageBus = new SQSMessageBus(o => { o.ConnectionString = connectionString; o.Topic = "events"; // Optional: Specify queue name for durable subscriptions // o.SubscriptionQueueName = "my-service-queue"; }); ``` ## Basic Usage ### Publishing Messages ```csharp var messageBus = new InMemoryMessageBus(); // Simple publish await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }); // With options await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }, new MessageOptions { CorrelationId = "request-abc", DeliveryDelay = TimeSpan.FromSeconds(30), Properties = new Dictionary { ["source"] = "order-service" } }); // Delayed publish (extension method) await messageBus.PublishAsync( new OrderReminder { OrderId = 123 }, TimeSpan.FromHours(1) ); ``` ### Subscribing to Messages ```csharp var messageBus = new InMemoryMessageBus(); // Simple subscription await messageBus.SubscribeAsync(async order => { Console.WriteLine($"Processing order: {order.OrderId}"); }); // With cancellation token await messageBus.SubscribeAsync( async (order, ct) => { await ProcessOrderAsync(order, ct); }, cancellationToken ); // Synchronous handler await messageBus.SubscribeAsync(order => { Console.WriteLine($"Order: {order.OrderId}"); }); ``` ### Multiple Subscribers Each subscriber receives every message: ```csharp var messageBus = new InMemoryMessageBus(); // Handler 1: Logging await messageBus.SubscribeAsync(async order => { _logger.LogInformation("Order {OrderId} created", order.OrderId); }); // Handler 2: Notification await messageBus.SubscribeAsync(async order => { await _notificationService.SendAsync(order.CustomerId, "Order placed!"); }); // Handler 3: Analytics await messageBus.SubscribeAsync(async order => { await _analytics.TrackAsync("order_created", order.OrderId); }); // All three handlers receive this message await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }); ``` ## Message Types ### Define Your Messages ```csharp // Simple message public record OrderCreated { public int OrderId { get; init; } public DateTime CreatedAt { get; init; } public string CustomerId { get; init; } } // Message with interface for grouping public interface IOrderEvent { int OrderId { get; } } public record OrderShipped : IOrderEvent { public int OrderId { get; init; } public string TrackingNumber { get; init; } } public record OrderDelivered : IOrderEvent { public int OrderId { get; init; } public DateTime DeliveredAt { get; init; } } ``` ### Subscribe to Interface Subscribe to all messages implementing an interface: ```csharp // Receives OrderShipped, OrderDelivered, and any other IOrderEvent await messageBus.SubscribeAsync(async orderEvent => { _logger.LogInformation("Order event: {Type} for {OrderId}", orderEvent.GetType().Name, orderEvent.OrderId); }); ``` ### IMessage Interface Use the built-in `IMessage` interface for raw message access: ```csharp await messageBus.SubscribeAsync(async (IMessage message, CancellationToken ct) => { Console.WriteLine($"Type: {message.Type}"); Console.WriteLine($"Correlation ID: {message.CorrelationId}"); // Deserialize the data var order = message.GetBody(); }); ``` ## Common Patterns ### Event-Driven Architecture Decouple services with events: ```csharp // Order Service public class OrderService { private readonly IMessageBus _messageBus; public async Task CreateOrderAsync(CreateOrderRequest request) { var order = await _repository.CreateAsync(request); // Publish event for other services await _messageBus.PublishAsync(new OrderCreated { OrderId = order.Id, CustomerId = request.CustomerId, CreatedAt = DateTime.UtcNow }); } } // Inventory Service (separate process/service) public class InventoryService { public InventoryService(IMessageBus messageBus) { messageBus.SubscribeAsync(async order => { await ReserveInventoryAsync(order.OrderId); }); } } // Notification Service (separate process/service) public class NotificationService { public NotificationService(IMessageBus messageBus) { messageBus.SubscribeAsync(async order => { await SendConfirmationEmailAsync(order.CustomerId); }); } } ``` ### Cache Invalidation Coordinate cache across instances: ```csharp public class CacheInvalidationService { private readonly IMessageBus _messageBus; private readonly ICacheClient _localCache; public CacheInvalidationService(IMessageBus messageBus, ICacheClient localCache) { _messageBus = messageBus; _localCache = localCache; // Listen for invalidation messages _messageBus.SubscribeAsync(async msg => { await _localCache.RemoveAsync(msg.Key); }); } public async Task InvalidateAsync(string key) { // Remove locally await _localCache.RemoveAsync(key); // Notify other instances await _messageBus.PublishAsync(new CacheInvalidated { Key = key }); } } public record CacheInvalidated { public string Key { get; init; } } ``` ### Real-Time Updates Push updates to clients: ```csharp // Server-side public class NotificationHub { private readonly IMessageBus _messageBus; public NotificationHub(IMessageBus messageBus) { _messageBus = messageBus; // Forward bus messages to SignalR/WebSocket _messageBus.SubscribeAsync(async notification => { await _hubContext.Clients .User(notification.UserId) .SendAsync("notification", notification); }); } } // When something happens await messageBus.PublishAsync(new UserNotification { UserId = "user-123", Message = "Your order has shipped!" }); ``` ### Saga/Process Manager Coordinate multi-step processes: ```csharp public class OrderSaga { private readonly IMessageBus _messageBus; public OrderSaga(IMessageBus messageBus) { _messageBus = messageBus; // Step 1: Order created -> Reserve inventory _messageBus.SubscribeAsync(async order => { await ReserveInventoryAsync(order.OrderId); await _messageBus.PublishAsync(new InventoryReserved { OrderId = order.OrderId }); }); // Step 2: Inventory reserved -> Process payment _messageBus.SubscribeAsync(async evt => { await ProcessPaymentAsync(evt.OrderId); await _messageBus.PublishAsync(new PaymentProcessed { OrderId = evt.OrderId }); }); // Step 3: Payment processed -> Ship order _messageBus.SubscribeAsync(async evt => { await ShipOrderAsync(evt.OrderId); }); } } ``` ## Message Options Configure message delivery: ```csharp await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }, new MessageOptions { // Unique message identifier UniqueId = Guid.NewGuid().ToString(), // For tracing across services CorrelationId = Activity.Current?.Id, // Delayed delivery DeliveryDelay = TimeSpan.FromMinutes(5), // Custom properties Properties = new Dictionary { ["source"] = "order-service", ["version"] = "1.0" } }); ``` ## Delayed Message Delivery The `DeliveryDelay` option schedules messages for future delivery. This is useful for scenarios like: * **Eventual consistency** - Wait for data to propagate before processing * **Scheduled reminders** - Send notifications after a delay * **Retry with backoff** - Republish failed messages with increasing delays ### Basic Usage ```csharp // Using MessageOptions await messageBus.PublishAsync(new OrderReminder { OrderId = 123 }, new MessageOptions { DeliveryDelay = TimeSpan.FromMinutes(30) }); // Using extension method await messageBus.PublishAsync(new OrderReminder { OrderId = 123 }, TimeSpan.FromMinutes(30)); ``` ### Provider Support Different providers handle delayed delivery differently: | Provider | Implementation | Persistence | Survives Restart | |----------|---------------|-------------|------------------| | **InMemoryMessageBus** | In-memory timer | None | No | | **AzureServiceBusMessageBus** | Native `ScheduledEnqueueTime` | Azure | Yes | | **KafkaMessageBus** | In-memory timer | None | No | | **RabbitMQMessageBus** | Plugin or fallback | Plugin: Yes, Fallback: No | Plugin: Yes, Fallback: No | | **RedisMessageBus** | In-memory timer | None | No | | **SQSMessageBus** | In-memory timer | None | No | ### Native vs Fallback Implementation **Native implementations** (Azure Service Bus, RabbitMQ with plugin) persist the delayed message in the broker. The message survives application restarts and is delivered reliably. **Fallback implementations** hold the message in memory using a timer. This has important limitations: ::: warning Fallback Limitations * **Messages are lost on restart** - If your application restarts before the delay expires, the message is permanently lost * **Messages are discarded on disposal** - During graceful shutdown, pending delayed messages are discarded * **Best-effort delivery** - No guarantee the message will be delivered ::: ### RabbitMQ Plugin RabbitMQ requires the `rabbitmq_delayed_message_exchange` plugin for native delayed delivery: ```bash # Enable the plugin rabbitmq-plugins enable rabbitmq_delayed_message_exchange ``` The `RabbitMQMessageBus` automatically detects if the plugin is available and uses it when present. Otherwise, it falls back to the in-memory timer. ### When to Use Delayed Delivery **Appropriate use cases (fallback is acceptable):** * Cache invalidation * Non-critical notifications * Eventual consistency delays (e.g., waiting for Elasticsearch to refresh) **NOT appropriate for fallback:** * Financial transactions * Order processing * Any message where loss is unacceptable For guaranteed delayed delivery, use: * Azure Service Bus (native support) * RabbitMQ with the delayed message plugin * `IQueue` with `DeliveryDelay` for work items that must be processed ## Distributed Tracing Foundatio automatically integrates with .NET's distributed tracing infrastructure (`System.Diagnostics.Activity`) to enable end-to-end request tracing across services. ### Automatic CorrelationId Injection When you publish a message, Foundatio automatically captures the current trace context: ```csharp // If Activity.Current exists, its ID is automatically used as CorrelationId await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }); // The message will have: // - CorrelationId = Activity.Current?.Id // - Properties["TraceState"] = Activity.Current?.TraceStateString (if present) ``` ### Manual CorrelationId You can also set the `CorrelationId` explicitly: ```csharp await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }, new MessageOptions { CorrelationId = "my-custom-correlation-id" }); ``` When you provide a `CorrelationId`, the automatic injection is skipped. ### Trace Propagation When a subscriber receives a message, Foundatio: 1. Creates a new `Activity` with the message's `CorrelationId` as the parent 2. Restores the `TraceState` from message properties 3. Adds the `CorrelationId` to the logging scope This enables distributed tracing tools (like Application Insights, Jaeger, or Zipkin) to correlate requests across services. ### Accessing Trace Information In your subscriber, you can access the trace context: ```csharp await messageBus.SubscribeAsync(async (message, ct) => { // Access correlation ID var correlationId = message.CorrelationId; // Access custom properties var traceState = message.Properties.GetValueOrDefault("TraceState"); // Activity.Current is automatically set with the message's trace context _logger.LogInformation("Processing message with trace {TraceId}", Activity.Current?.TraceId); }); ``` ### Integration with OpenTelemetry Foundatio's tracing integrates seamlessly with OpenTelemetry: ```csharp services.AddOpenTelemetry() .WithTracing(builder => { builder.AddSource(FoundatioDiagnostics.ActivitySource.Name); // ... other configuration }); ``` ## Dependency Injection ### Basic Registration ```csharp // In-memory (development) services.AddSingleton(); // Redis (production) services.AddSingleton(sp => { var redis = sp.GetRequiredService(); return new RedisMessageBus(o => o.Subscriber = redis.GetSubscriber()); }); ``` ### Subscribe at Startup ```csharp public class MessageSubscriber : IHostedService { private readonly IMessageBus _messageBus; private readonly IServiceProvider _services; public MessageSubscriber(IMessageBus messageBus, IServiceProvider services) { _messageBus = messageBus; _services = services; } public async Task StartAsync(CancellationToken cancellationToken) { await _messageBus.SubscribeAsync(async (msg, ct) => { using var scope = _services.CreateScope(); var handler = scope.ServiceProvider.GetRequiredService(); await handler.HandleAsync(msg, ct); }, cancellationToken); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } // Register services.AddHostedService(); ``` ## Error Handling ### MessageBusException All message bus implementations throw `MessageBusException` for transport-level errors. This provides a consistent exception type regardless of the underlying provider: ```csharp try { await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }); } catch (MessageBusException ex) { _logger.LogError(ex, "Failed to publish message: {Message}", ex.Message); // Handle transport error (network, broker unavailable, etc.) } catch (OperationCanceledException) { // Handle cancellation } ``` **Exception Behavior:** | Scenario | Exception Type | Notes | |----------|---------------|-------| | Transport error (network, broker) | `MessageBusException` | Wraps underlying exception | | Null message/type | `ArgumentNullException` | Thrown immediately | | Cancellation requested | `OperationCanceledException` | Passed through unchanged | | Serialization error | `MessageBusException` | Wraps serialization exception | ### Subscriber Error Handling **Important:** Subscriber errors do NOT propagate to the publisher. This behavior is consistent across ALL implementations, including `InMemoryMessageBus`. This design ensures: 1. **Consistent behavior** - Code that works with `InMemoryMessageBus` in tests will behave the same with distributed buses in production 2. **Matches distributed reality** - In distributed systems, publishers and subscribers run in separate processes; publisher cannot see subscriber errors 3. **Predictable error handling** - Subscribers are responsible for handling their own errors ```csharp // Publisher - will NOT see subscriber errors await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }); // Returns successfully even if a subscriber throws // Subscriber - handle your own errors await messageBus.SubscribeAsync(async order => { try { await ProcessOrderAsync(order); } catch (Exception ex) { _logger.LogError(ex, "Failed to process order {OrderId}", order.OrderId); // Optionally publish failure event, retry, etc. } }); ``` ### Provider Behavior Summary | Provider | Publish Errors | Subscriber Errors | Redelivery on Failure | |----------|---------------|-------------------|----------------------| | **InMemoryMessageBus** | Throws `MessageBusException` | Logged, swallowed | No | | **AzureServiceBusMessageBus** | Throws `MessageBusException` | Logged, SDK handles | Yes (`MaxDeliveryCount`) | | **KafkaMessageBus** | Fire-and-forget with callback | Logged, offset not committed | Yes (redelivered) | | **RabbitMQMessageBus** | Throws `MessageBusException` | Logged, nack/requeue | Yes (`DeliveryLimit`) | | **RedisMessageBus** | Throws `MessageBusException` | Logged, swallowed | No (pub/sub has no ack) | | **SQSMessageBus** | Throws `MessageBusException` | Logged, message not deleted | Yes (redelivered) | ::: tip Logging All errors are logged at `Error` level. Subscriber errors are logged exactly once by the base class. ::: ### In Subscribers ```csharp await messageBus.SubscribeAsync(async order => { try { await ProcessOrderAsync(order); } catch (Exception ex) { _logger.LogError(ex, "Failed to process order {OrderId}", order.OrderId); // Optionally publish failure event await _messageBus.PublishAsync(new OrderProcessingFailed { OrderId = order.OrderId, Error = ex.Message }); } }); ``` ### With Retry ```csharp await messageBus.SubscribeAsync(async order => { await _resiliencePolicy.ExecuteAsync(async ct => { await ProcessOrderAsync(order, ct); }); }); ``` ## Cancellation Token Behavior Understanding how cancellation tokens are handled internally is important for building reliable publishers and subscribers. ### Resource Creation Uses Disposal Token When you call `PublishAsync` or `SubscribeAsync`, the message bus may need to create infrastructure (e.g., Azure Service Bus topics, RabbitMQ exchanges, SQS topics). These setup operations use an internal disposal token — **not** the caller's cancellation token. This means: * **Topic and subscription creation only abort when the message bus is disposed**, never because a single caller cancelled their operation. * A cancelled publish will not leave topic infrastructure in a half-created state. * Multiple concurrent publishers/subscribers cannot interfere with each other's setup. ### Linked Cancellation for Publish The caller's cancellation token is combined with the disposal token into a linked token for the actual publish operation. This means: * Publish cancels when **either** the caller cancels **or** the message bus is disposed. * Graceful shutdown via `Dispose()` cancels all in-flight publishes promptly. ```csharp // Topic creation always completes (unless disposed), even if the publish is cancelled using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }, cancellationToken: cts.Token); ``` ### For Implementation Authors If you are writing a custom `IMessageBus` implementation by extending `MessageBusBase`: * **`EnsureTopicCreatedAsync`** always receives `DisposedCancellationToken`. Use it for all setup operations (lock acquisition, API calls, etc.). * **`EnsureTopicSubscriptionAsync`** always receives `DisposedCancellationToken`. Use it for subscription infrastructure setup. * **`PublishImplAsync`** receives a linked token (caller + disposal). Respect it for the actual message send. ## Best Practices ### 1. Use Immutable Messages ```csharp // ✅ Good: Immutable record public record OrderCreated { public int OrderId { get; init; } public required string CustomerId { get; init; } } // ❌ Bad: Mutable class public class OrderCreated { public int OrderId { get; set; } public string CustomerId { get; set; } } ``` ### 2. Include Timestamp and Correlation ```csharp public record OrderCreated { public int OrderId { get; init; } public DateTime OccurredAt { get; init; } = DateTime.UtcNow; public string CorrelationId { get; init; } = Activity.Current?.Id; } ``` ### 3. Handle Idempotency ```csharp await messageBus.SubscribeAsync(async order => { // Check if already processed if (await _processedEvents.ContainsAsync(order.EventId)) { _logger.LogDebug("Already processed {EventId}", order.EventId); return; } await ProcessOrderAsync(order); await _processedEvents.AddAsync(order.EventId); }); ``` ### 4. Use Specific Message Types ```csharp // ✅ Good: Specific, intentional messages public record OrderCreated { ... } public record OrderShipped { ... } public record OrderCancelled { ... } // ❌ Bad: Generic, multi-purpose messages public record OrderEvent { public string Action { get; set; } } ``` ### 5. Keep Messages Small Messages should contain identifiers and essential data only, not full entity payloads. ```csharp // ✅ Good: Just identifiers public record OrderCreated { public int OrderId { get; init; } } // ❌ Bad: Full entity in message public record OrderCreated { public Order FullOrderWithAllDetails { get; init; } } ``` ## Message Size Limits Different message bus implementations have different size limits. Understanding these limits is essential for reliable messaging. | Provider | Max Message Size | Notes | |----------|------------------|-------| | InMemoryMessageBus | Limited by available memory | No practical limit | | AzureServiceBusMessageBus | 256 KB (Standard) / 100 MB (Premium) | Use claim check for large payloads | | KafkaMessageBus | 1 MB (default) | Configurable via `message.max.bytes` | | RabbitMQMessageBus | 128 MB (default) | Configurable, but keep small | | RedisMessageBus | 512 MB (Redis limit) | Recommended: < 1 MB for performance | | SQSMessageBus | 256 KB | Use claim check for large payloads | ### Claim Check Pattern for Large Payloads For large data, store it externally and pass a reference (also known as the Claim Check Pattern): ```csharp // Instead of embedding large data public record DocumentProcessed { public string DocumentId { get; init; } public string BlobPath { get; init; } // Reference to storage public long SizeBytes { get; init; } } // Subscriber retrieves from storage await messageBus.SubscribeAsync(async msg => { var document = await _fileStorage.GetObjectAsync(msg.BlobPath); await ProcessDocumentAsync(document); }); ``` ## Notification Patterns ### Real-Time Notifications with SignalR ```csharp public class NotificationService : IHostedService { private readonly IMessageBus _messageBus; private readonly IHubContext _hubContext; public NotificationService(IMessageBus messageBus, IHubContext hubContext) { _messageBus = messageBus; _hubContext = hubContext; } public async Task StartAsync(CancellationToken ct) { // Bridge message bus to SignalR await _messageBus.SubscribeAsync(async (msg, ct) => { await _hubContext.Clients .User(msg.UserId) .SendAsync("Notification", msg.Title, msg.Body, ct); }, ct); // Broadcast to all users await _messageBus.SubscribeAsync(async (msg, ct) => { await _hubContext.Clients.All .SendAsync("Announcement", msg.Message, ct); }, ct); } public Task StopAsync(CancellationToken ct) => Task.CompletedTask; } ``` ### Delayed Notifications ```csharp // Schedule a reminder await messageBus.PublishAsync(new ReminderNotification { UserId = "user-123", Message = "Don't forget to complete your order!" }, new MessageOptions { DeliveryDelay = TimeSpan.FromHours(24) }); ``` ### Fan-Out Pattern Publish once, process in multiple ways: ```csharp // Single publish await messageBus.PublishAsync(new OrderCreated { OrderId = 123 }); // Multiple subscribers handle different concerns await messageBus.SubscribeAsync(async order => { await _emailService.SendConfirmationAsync(order.OrderId); }); await messageBus.SubscribeAsync(async order => { await _inventoryService.ReserveAsync(order.OrderId); }); await messageBus.SubscribeAsync(async order => { await _analyticsService.TrackAsync("order_created", order.OrderId); }); ``` ## Resource Management ### Disposal Lifecycle Message buses implement both `IDisposable` and `IAsyncDisposable`. Prefer `await using` (or `DisposeAsync()`) for clean shutdown: ```csharp // Preferred: async disposal await using var messageBus = new InMemoryMessageBus(); await messageBus.SubscribeAsync(async e => { /* ... */ }); // DisposeAsync is called when scope ends // DI container manages lifetime automatically services.AddSingleton(); ``` Disposal follows a **two-phase** sequence to prevent message loss in durable providers: 1. **Graceful drain** — In-flight handlers finish executing while subscribers and the internal cancellation token are still active. Providers that support processor-level draining (e.g., Azure Service Bus `StopProcessingAsync`) execute it here via `ShutdownAsync`. 2. **Teardown** — The internal cancellation token is cancelled, all subscribers are cleared, and transport infrastructure (connections, channels, clients) is closed and disposed via `CleanupAsync`. > **Note:** The base `MessageBusBase` implementation does not guarantee that all active subscriber callbacks have completed before `DisposeAsync` returns. Provider-specific draining behavior (such as Azure Service Bus `StopProcessingAsync`) is implemented in provider overrides of `ShutdownAsync`. ### Message Durability During Shutdown What happens to messages that arrive while the bus is disposing depends on the provider: | Provider | In-Flight Messages | Arriving During Dispose | After Dispose | |---|---|---|---| | **InMemoryMessageBus** | Completed normally | Dropped (no persistence) | Lost | | **AzureServiceBusMessageBus** | Completed; abandoned if bus disposes mid-handler (PeekLock) | Remain in topic for other subscribers | Persisted in Azure | | **KafkaMessageBus** | Completed; offset not committed if bus disposes mid-handler | Remain in partition (uncommitted offset) | Persisted in Kafka | | **RabbitMQMessageBus** | Completed; requeued if bus disposes mid-handler | Remain in queue | Persisted in RabbitMQ | | **RedisMessageBus** | Completed normally | Dropped (pub/sub has no persistence) | Lost | | **SQSMessageBus** | Completed; message not deleted if bus disposes mid-handler | Remain in SQS queue | Persisted in SQS | ::: tip Fire-and-Forget Providers `InMemoryMessageBus` and `RedisMessageBus` use fire-and-forget delivery. Messages that arrive when no subscriber is listening are permanently lost. This is by design — if you need guaranteed delivery, use a durable provider or `IQueue`. ::: ### Writing Custom Providers If you are extending `MessageBusBase` with a custom provider, override the lifecycle hooks: * **`ShutdownAsync()`** — Called *before* the cancellation token is cancelled and *before* subscribers are cleared. Use this to gracefully drain your transport (e.g., stop a processor, close a consumer group). Subscribers are still active and can finish processing. * **`CleanupAsync()`** — Called *after* the cancellation token is cancelled and *after* subscribers are cleared. Use this to tear down transport infrastructure (close connections, dispose clients, await background tasks). ```csharp public class MyMessageBus : MessageBusBase { // Phase 1: Stop accepting new messages, drain in-flight work. // When the body is a single call, return the Task directly (no extra async state machine). protected override Task ShutdownAsync() => _processor.StopAsync(); protected override async Task CleanupAsync() { // Phase 2: Close connections, dispose clients — ConfigureAwait(false) in library overrides await _connection.CloseAsync().ConfigureAwait(false); _client.Dispose(); } } ``` If `ShutdownAsync` needs multiple steps, use `async`/`await` and apply `.ConfigureAwait(false)` to each await (within Foundatio provider projects, the same pattern uses the internal `AnyContext()` helper). ## Next Steps * [Queues](./queues) - For guaranteed delivery with acknowledgment * [Caching](./caching) - Cache invalidation with messaging * [Jobs](./jobs) - Background processing triggered by messages --- --- url: /guide/nullable-reference-types.md --- # Nullable Reference Types (NRT) Migration Foundatio has been fully annotated with C# [nullable reference types](https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references) across the core library and all provider repositories. This document describes the public API changes, design decisions, and remaining areas for improvement. ## Interface Return Type Changes These are **breaking changes** for consumers who must now handle nullable return values. All changes are semantically correct — they represent cases where `null` was always a possible runtime value but wasn't expressed in the type system. ### Lock Provider | Before | After | Reason | |--------|-------|--------| | `Task AcquireAsync(...)` | `Task AcquireAsync(...)` | Lock acquisition can fail (timeout, contention) | ### Queue | Before | After | Reason | |--------|-------|--------| | `Task EnqueueAsync(...)` | `Task EnqueueAsync(...)` | Returns `null` when `Enqueuing` event cancels the operation | | `Task> DequeueAsync(...)` | `Task?> DequeueAsync(...)` | Returns `null` on timeout or cancellation | ### File Storage | Before | After | Reason | |--------|-------|--------| | `Task GetFileStreamAsync(...)` | `Task GetFileStreamAsync(...)` | Returns `null` when file does not exist | | `Task GetFileInfoAsync(...)` | `Task GetFileInfoAsync(...)` | Returns `null` when file does not exist | ### Serializer | Before | After | Reason | |--------|-------|--------| | `object Deserialize(...)` | `object? Deserialize(...)` | Deserialized data can be `null` | ### Messaging | Before | After | Reason | |--------|-------|--------| | `string IMessage.UniqueId` | `string? IMessage.UniqueId` | Optional field, not always set | | `string IMessage.CorrelationId` | `string? IMessage.CorrelationId` | Optional correlation tracking field | | `Type IMessage.ClrType` | `Type? IMessage.ClrType` | Type may not be resolvable at runtime | | `object IMessage.GetBody()` | `object? IMessage.GetBody()` | Body deserialization can return `null` | ### Queue Entry | Before | After | Reason | |--------|-------|--------| | `string IQueueEntry.CorrelationId` | `string? IQueueEntry.CorrelationId` | Optional field | | `Type IQueueEntry.EntryType` | `Type? IQueueEntry.EntryType` | Type resolution can fail | ### Resilience | Before | After | Reason | |--------|-------|--------| | `IResiliencePolicy GetPolicy(...)` | `IResiliencePolicy? GetPolicy(...)` | Can return `null` when `useDefault: false` and policy doesn't exist | ### Parameter Nullability These parameter changes allow callers to pass `null` where it was already supported at runtime: | Interface | Parameter Change | Reason | |-----------|-----------------|--------| | `ICacheClient.RemoveAllAsync` | `IEnumerable? keys = null` | `null` flushes all keys | | `IFileStorage.DeleteFilesAsync` | `string? searchPattern = null` | `null` deletes all files | | `IFileStorage.GetPagedFileListAsync` | `string? searchPattern = null` | `null` lists all files | | `IMessagePublisher.PublishAsync` | `MessageOptions? options = null` | Options are optional | ### Other Interface Properties | Before | After | Reason | |--------|-------|--------| | `string IHaveSubMetricName.SubMetricName` | `string? IHaveSubMetricName.SubMetricName` | Sub-metric is optional | | `string IHaveUniqueIdentifier.UniqueIdentifier` | `string? IHaveUniqueIdentifier.UniqueIdentifier` | Identifier is optional | ## Migration Impact for Consumers ### What You Need to Change 1. **Null checks on return values**: Methods that now return nullable types require null checks: ```csharp // Before var entry = await queue.DequeueAsync(token); await entry.CompleteAsync(); // no null check needed // After var entry = await queue.DequeueAsync(token); if (entry is null) return; await entry.CompleteAsync(); ``` 2. **Nullable property access**: Properties like `IMessage.CorrelationId`, `IMessage.ClrType`, and `IQueueEntry.CorrelationId` are now nullable. ::: info Queue Job Null Safety `IQueueJob.ProcessAsync` takes a **non-nullable** `IQueueEntry` parameter. `QueueJobBase.RunAsync()` already checks for `null` after dequeuing and returns early — `ProcessAsync` is never called with a `null` entry. ::: ## Known `null!` Patterns (Bandaids) The following areas use `= null!` as a default value to suppress NRT warnings. These are functional but not ideal — the `required` keyword (C# 11+) would be more correct for properties that must always be set. ## `required` Keyword Usage The following types use the C# 11 `required` keyword to enforce initialization at construction time. This replaces the previous `= null!` pattern and provides compile-time safety. ### Queue Event Args All queue event args classes use `required` for properties that are always populated by the queue infrastructure: * `EnqueuingEventArgs`: `Queue`, `Data`, `Options` * `EnqueuedEventArgs`: `Queue`, `Entry` * `DequeuedEventArgs`: `Queue`, `Entry` * `LockRenewedEventArgs`: `Queue`, `Entry` * `CompletedEventArgs`: `Queue`, `Entry` * `AbandonedEventArgs`: `Queue`, `Entry` * `QueueDeletedEventArgs`: `Queue` ### DTOs and Models | Class | Properties with `required` | |-------|--------------------------| | `FileSpec` | `Path` | | `WorkItemData` | `WorkItemId`, `Type`, `Data` | | `InvalidateCache` | `CacheId` | | `ItemExpiredEventArgs` | `Client`, `Key` | | `CacheLockReleased` | `Resource` | | `NextPageResult` | `Files` | | `MessageBusBase.Subscriber` | `Type`, `Action` | ::: warning `required` on serialized types enforces the property is present during `System.Text.Json` deserialization — a missing property throws `JsonException`. This is intentional: `WorkItemData`, `InvalidateCache`, and `CacheLockReleased` are all serialized over queues or message bus, and their required properties should always be present in the payload. ::: ### Remaining `null!` Patterns The following areas still use `= null!`: | Class | Field/Property | Reason | |-------|---------------|--------| | `QueueBehaviorBase` | `_queue` field | Set via `Attach()` before any other method is called. Cannot use `required` on a `protected` field set by an interface method. | ### DI Service Resolution `SharedOptions.UseServices()` uses `null!` when assigning services from DI: ```csharp options.ResiliencePolicyProvider = serviceProvider.GetService()!; ``` This is safe because `SharedOptions` properties have fallback defaults in their getters (e.g., `DefaultResiliencePolicyProvider.Instance`), but the `null!` suppresses the warning that `GetService` can return `null`. ### Scoped Wrappers `ScopedCacheClient`, `ScopedLockProvider`, and `ScopedFileStorage` implement `IHaveResiliencePolicyProvider` by delegating to the inner instance with `null!`: ```csharp IResiliencePolicyProvider IHaveResiliencePolicyProvider.ResiliencePolicyProvider => UnscopedCache.GetResiliencePolicyProvider()!; ``` This can return `null` at runtime if the inner instance doesn't implement `IHaveResiliencePolicyProvider`. ### Builder Extension Methods `FoundatioServicesExtensions` uses `options = null!` as default parameter values: ```csharp public FoundatioBuilder UseInMemory(InMemoryCacheClientOptions options = null!) ``` This is safe because `UseServices()` handles `null` via `options ??= new TOption()`. ## Provider Repository Patterns All provider repositories follow the same pattern for options classes with connection strings: | Provider | Options Property | |----------|-----------------| | Redis | `ConnectionMultiplexer = null!`, `Subscriber = null!` | | Azure Service Bus | `ConnectionString = null!`, `FullyQualifiedNamespace = null!`, `Credential = null!` | | Azure Storage | `ConnectionString = null!` | | RabbitMQ | `ConnectionString = null!` | | AWS | Queue entry `Data = null!` | | Aliyun | `ConnectionString = null!` | | Minio | `Endpoint = null!`, `AccessKey = null!`, `SecretKey = null!` | | SSH.NET | `ConnectionString = null!` | | Kafka | Options properties | ## Recommendations for Future Improvement ### Make DI Resolution Explicit Replace `GetService()!` in `SharedOptions.UseServices()` with explicit null handling: ```csharp var provider = serviceProvider.GetService(); if (provider is not null) options.ResiliencePolicyProvider = provider; ``` This avoids the `null!` and correctly preserves the fallback defaults. ### Scoped Wrapper Safety The scoped wrappers (`ScopedCacheClient`, etc.) should handle the case where the inner instance doesn't implement `IHaveResiliencePolicyProvider`: ```csharp IResiliencePolicyProvider IHaveResiliencePolicyProvider.ResiliencePolicyProvider => UnscopedCache.GetResiliencePolicyProvider() ?? DefaultResiliencePolicyProvider.Instance; ``` --- --- url: /guide/provider-behavioral-gaps.md --- # Provider Behavioral Gaps This document catalogs known behavioral differences across Foundatio provider implementations. While all providers implement the same interfaces, underlying infrastructure limitations mean some operations behave differently than the interface contract might suggest. Use this as a compatibility reference when choosing or switching providers. ## Summary | Interface | InMemory | Redis | Azure | AWS | RabbitMQ | Kafka | Minio | Aliyun | SshNet | |-----------|----------|-------|-------|-----|----------|-------|-------|--------|--------| | `IFileStorage` | Full | Full | Full | Partial | — | — | Partial | Full | Full | | `IQueue` | Full | Partial | Partial | Partial | — | — | — | — | — | | `IMessageBus` | Full | Full | Full | Partial | Full | Full | — | — | — | | `ICacheClient` | Full | Full | — | — | — | — | — | — | — | | `ILockProvider` | Full | Full | — | — | — | — | — | — | — | **Legend:** Full = all interface behaviors work as documented. Partial = some operations diverge (see below). — = not implemented by this provider. *** ## IFileStorage ### DeleteFileAsync for Non-Existent Files | Provider | Behavior | Notes | |----------|----------|-------| | InMemory | Returns `false` | ✅ Expected | | Redis | Returns `false` | ✅ Expected | | Azure Blob | Returns `false` | ✅ Expected | | S3 | Returns `true` | ⚠️ S3 DELETE is idempotent by design | | Minio | Returns `true` | ⚠️ S3-compatible behavior | | Aliyun | Returns `false` | ✅ Expected | | SshNet | Returns `false` | ✅ Expected | **Impact:** Code that uses the return value to determine whether a file existed before deletion will get incorrect results on S3/Minio. ### CopyFileAsync / RenameFileAsync with Non-Existent Source | Provider | Behavior | Notes | |----------|----------|-------| | InMemory | Returns `false` | ✅ Expected | | Redis | Returns `false` | ✅ Expected | | Azure Blob | Returns `false` | ✅ Expected | | S3 | Throws `AmazonS3Exception` | ⚠️ Exception instead of `false` | | Minio | Returns `false` | ✅ Expected | | Aliyun | Returns `false` | ✅ Expected | | SshNet | Returns `false` | ✅ Expected | **Impact:** Callers must wrap S3 copy/rename in try-catch if the source file might not exist. ### GetFileStreamAsync with Non-Existent File (ReadMode) | Provider | Behavior | Notes | |----------|----------|-------| | InMemory | Returns `null` | ✅ Expected | | Redis | Returns `null` | ✅ Expected | | Azure Blob | Returns `null` | ✅ Expected | | S3 | Throws `AmazonS3Exception` | ⚠️ Exception instead of `null` | | Minio | Returns `null` | ✅ Expected | | Aliyun | Returns `null` | ✅ Expected | | SshNet | Returns `null` | ✅ Expected | ### GetFileStreamAsync with StreamMode.Write | Provider | Supported | Notes | |----------|-----------|-------| | InMemory | ✅ | Full support | | Azure Blob | ✅ | Full support | | Aliyun | ✅ | Full support | | SshNet | ✅ | Full support | | Redis | ❌ | Not supported | | S3 | ❌ | Not supported | | Minio | ❌ | Not supported | *** ## IQueue\ ### GetDeadletterItemsAsync | Provider | Supported | Notes | |----------|-----------|-------| | InMemory | ✅ | Returns all deadlettered entries | | Redis | ❌ | Not implemented | | Azure Service Bus | ❌ | Deadletter is a separate sub-queue; requires different API | | Azure Storage Queue | ❌ | No deadletter concept in Azure Storage Queues | | SQS | ❌ | Deadletter is a separate queue; not retrievable via this API | ### QueueEntryOptions.UniqueId | Provider | Supported | Notes | |----------|-----------|-------| | InMemory | ✅ | Uses provided ID as the entry ID | | Redis | ✅ | Uses provided ID as the entry ID | | Azure Service Bus | ✅ | Maps to MessageId | | Azure Storage Queue | ❌ | Azure assigns its own MessageId | | SQS | ❌ | SQS assigns its own MessageId | ### Delivery Delay (DelayUntilUtc) | Provider | Supported | Granularity | Notes | |----------|-----------|-------------|-------| | InMemory | ✅ | Millisecond | Precise delay via background timer | | Redis | ❌ | — | Not supported | | Azure Storage Queue | ✅ | Second | `VisibilityTimeout` parameter | | Azure Service Bus | ✅ | Second | `ScheduledEnqueueTimeUtc` | | SQS | ✅ | Second (0-900s max) | `DelaySeconds` parameter | *** ## IMessageBus ### Delayed Message Delivery | Provider | Supported | Granularity | Notes | |----------|-----------|-------------|-------| | InMemory | ✅ | Millisecond | Background timer, precise | | Redis | ✅ | Millisecond | Lua script with delay | | Azure Service Bus | ✅ | Second | Scheduled enqueue | | RabbitMQ | ✅ | Millisecond | Via delayed-message-exchange plugin | | Kafka | ✅ | Millisecond | Client-side delay before produce | | SQS/SNS | ⚠️ | Second | Impractical due to polling latency | **Note:** For distributed providers, delay precision is limited by network latency and polling intervals. Tests use 1-second minimum delay to accommodate provider-level granularity. ### Message Ordering Guarantees | Provider | Ordered | Notes | |----------|---------|-------| | InMemory | ✅ | FIFO within a single subscriber | | Redis | ✅ | Pub/sub delivers in publish order | | Azure Service Bus | ✅ | FIFO with sessions enabled | | RabbitMQ | ✅ | FIFO per queue | | Kafka | ✅ | FIFO per partition | | SQS/SNS | ⚠️ | Standard queues are best-effort ordering | *** ## ICacheClient All tested providers (InMemory, Redis) exhibit fully consistent behavior. No behavioral gaps were found for: * `AddAsync` (returns `false` when key exists) * `GetAsync` (returns no-value for missing keys) * `RemoveAsync` (returns `false` for non-existent keys) * `RemoveByPrefixAsync` (returns 0 for no matches) * `IncrementAsync` (with zero amount returns current value) * `SetExpirationAsync` (no-op for non-existent keys) *** ## ILockProvider All tested providers (InMemory, Redis) exhibit fully consistent behavior. No behavioral gaps were found for: * `AcquireAsync` with `releaseOnDispose: false` * `ReleaseAsync` with force release * `ILock` metadata properties (`LockId`, `Resource`, `AcquiredTimeUtc`) * `TryUsingAsync` execution and automatic release *** ## Recommendations 1. **Don't rely on `DeleteFileAsync` return value for existence checks** on S3-compatible storage. Use `GetFileInfoAsync` first if you need to know whether a file existed. 2. **Wrap S3 copy/rename/read operations in try-catch** if the source file might not exist. Other providers return `false`/`null` gracefully. 3. **Use at least 1-second granularity for delivery delays** when targeting distributed providers. Sub-second delays are only reliable with InMemory. 4. **Don't rely on `GetDeadletterItemsAsync`** — only InMemory supports it. Design deadletter processing around provider-specific mechanisms instead. 5. **Prefer `QueueEntryOptions.UniqueId` only with providers that support it** (InMemory, Redis, Azure Service Bus). On SQS and Azure Storage Queues, the system assigns its own IDs. --- --- url: /guide/queues.md --- # Queues Queues offer First In, First Out (FIFO) message delivery with reliable processing semantics. Foundatio provides multiple queue implementations through the `IQueue` interface. ## The IQueue Interface [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Queues/IQueue.cs) ```csharp public interface IQueue : IQueue where T : class { AsyncEvent> Enqueuing { get; } AsyncEvent> Enqueued { get; } AsyncEvent> Dequeued { get; } AsyncEvent> LockRenewed { get; } AsyncEvent> Completed { get; } AsyncEvent> Abandoned { get; } AsyncEvent> QueueDeleted { get; } void AttachBehavior(IQueueBehavior behavior); Task EnqueueAsync(T data, QueueEntryOptions? options = null); Task?> DequeueAsync(CancellationToken cancellationToken); Task?> DequeueAsync(TimeSpan? timeout = null); Task RenewLockAsync(IQueueEntry queueEntry); Task CompleteAsync(IQueueEntry queueEntry); Task AbandonAsync(IQueueEntry queueEntry); Task> GetDeadletterItemsAsync(CancellationToken cancellationToken = default); Task StartWorkingAsync(Func, CancellationToken, Task> handler, bool autoComplete = false, CancellationToken cancellationToken = default); } public interface IQueue : IHaveSerializer, IDisposable { Task GetQueueStatsAsync(); Task DeleteQueueAsync(); string QueueId { get; } } ``` ## Implementations ### InMemoryQueue An in-memory queue implementation for development and testing: [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Queues/InMemoryQueue.cs) ```csharp using Foundatio.Queues; var queue = new InMemoryQueue(); // Enqueue work await queue.EnqueueAsync(new WorkItem { Id = 1, Data = "Hello" }); // Dequeue and process var entry = await queue.DequeueAsync(); if (entry != null) { Console.WriteLine($"Processing: {entry.Value.Data}"); await entry.CompleteAsync(); } ``` ### RedisQueue Distributed queue using Redis (separate package): [View source](https://github.com/FoundatioFx/Foundatio.Redis/blob/main/src/Foundatio.Redis/Queues/RedisQueue.cs) ```csharp // dotnet add package Foundatio.Redis using Foundatio.Redis.Queues; var queue = new RedisQueue(o => { o.ConnectionMultiplexer = redis; o.Name = "work-items"; o.WorkItemTimeout = TimeSpan.FromMinutes(5); }); ``` ### AzureServiceBusQueue Queue using Azure Service Bus (separate package): [View source](https://github.com/FoundatioFx/Foundatio.AzureServiceBus/blob/main/src/Foundatio.AzureServiceBus/Queues/AzureServiceBusQueue.cs) ```csharp // dotnet add package Foundatio.AzureServiceBus using Foundatio.AzureServiceBus.Queues; var queue = new AzureServiceBusQueue(o => { o.ConnectionString = "..."; o.Name = "work-items"; }); ``` ### AzureStorageQueue Queue using Azure Storage Queues (separate package): [View source](https://github.com/FoundatioFx/Foundatio.AzureStorage/blob/main/src/Foundatio.AzureStorage/Queues/AzureStorageQueue.cs) ```csharp // dotnet add package Foundatio.AzureStorage using Foundatio.AzureStorage.Queues; var queue = new AzureStorageQueue(o => { o.ConnectionString = "..."; o.Name = "work-items"; }); ``` ### SQSQueue Queue using AWS SQS (separate package): [View source](https://github.com/FoundatioFx/Foundatio.AWS/blob/main/src/Foundatio.AWS/Queues/SQSQueue.cs) ```csharp // dotnet add package Foundatio.AWS using Foundatio.AWS.Queues; var queue = new SQSQueue(o => { o.Region = RegionEndpoint.USEast1; o.QueueName = "work-items"; }); ``` ## Queue Entry Lifecycle Each dequeued message goes through a lifecycle: ```txt ┌─────────┐ │ Queued │ └────┬────┘ │ ▼ ┌──────────────────┐ │ Dequeued/Working │ └────┬─────────────┘ │ ▼ ┌──────────────┐ │ Processing │ └──┬────────┬──┘ │ │ Success│ │Failure │ │ ▼ ▼ ┌────────┐ ┌───────────┐ │Complete│ │ Abandoned │ └────────┘ └─────┬─────┘ │ ▼ ┌─────────┐ │ Retry? │ └──┬───┬──┘ Yes │ │ No │ │ ▼ ▼ ┌─────────┐ ┌──────────────┐ │ Queued │ │ Dead Letter │ └─────────┘ └──────────────┘ ``` ### Completing Entries Mark an entry as successfully processed: ```csharp var entry = await queue.DequeueAsync(); if (entry is null) return; try { await ProcessAsync(entry.Value); await entry.CompleteAsync(); } catch { await entry.AbandonAsync(); throw; } ``` ### Abandoning Entries Return an entry to the queue for retry: ```csharp var entry = await queue.DequeueAsync(); if (entry is null) return; if (!CanProcess(entry.Value)) { // Return to queue for later processing await entry.AbandonAsync(); return; } ``` ### Lock Renewal When processing takes longer than the `WorkItemTimeout`, the queue entry's lock may expire, causing another worker to pick up the same item. Use `RenewLockAsync` to extend the lock duration. **Why lock renewal matters:** * Prevents duplicate processing when work takes longer than expected * Avoids entries being re-queued while still being processed * Essential for variable-duration workloads ::: tip Recommended Approach Use `QueueJobBase` for queue processing (see [Jobs - Queue Processor Jobs](/guide/jobs#queue-processor-jobs)). For manual processing, call `RenewLockAsync()` periodically within your processing logic. ::: **Best practices for `WorkItemTimeout`:** * Set `WorkItemTimeout` to your typical processing time plus padding (e.g., 2x normal duration) * Call `RenewLockAsync()` before the timeout expires if processing takes longer than expected * Monitor your processing times to adjust the timeout appropriately #### Manual Renewal in Queue Jobs For long-running operations in a `QueueJobBase`, renew the lock during processing: ```csharp public class VideoProcessorJob : QueueJobBase { private readonly IVideoService _videoService; public VideoProcessorJob(IQueue queue, IVideoService videoService) : base(queue) => _videoService = videoService; protected override async Task ProcessQueueEntryAsync( QueueEntryContext context) { var workItem = context.QueueEntry.Value; var startTime = DateTime.UtcNow; try { // Start processing await _videoService.StartProcessingAsync(workItem.VideoId); // Renew lock if processing is taking longer than expected if (DateTime.UtcNow - startTime > TimeSpan.FromMinutes(3)) { await context.QueueEntry.RenewLockAsync(); } await _videoService.CompleteProcessingAsync(workItem.VideoId); return JobResult.Success; } catch (Exception ex) { return JobResult.FromException(ex); } } } ``` ::: warning Manual Lock Renewal Most processing should complete within the `WorkItemTimeout`. If you regularly need lock renewal, increase the `WorkItemTimeout` instead. Manual renewal should only be used for truly variable-duration workloads where you cannot predict processing time accurately. ::: #### Ensuring Single Processing with GetQueueEntryLockAsync Override `GetQueueEntryLockAsync` to acquire a distributed lock based on a unique value from the work item. This guarantees that even if the same item is enqueued multiple times (e.g., due to retries or system failures), only one instance will process it at a time. **When to use this:** * Processing must be guaranteed to occur only once per unique identifier * Work items can be re-queued due to failures, but duplicate processing would cause issues * You need to lock on a business key (e.g., user ID, order ID) rather than the queue entry ID ```csharp public class OrderProcessorJob : QueueJobBase { private readonly ILockProvider _lockProvider; private readonly IOrderService _orderService; public OrderProcessorJob( IQueue queue, ILockProvider lockProvider, IOrderService orderService) : base(queue) { _lockProvider = lockProvider; _orderService = orderService; } // Override to lock on the order ID instead of the queue entry ID protected override Task GetQueueEntryLockAsync( IQueueEntry queueEntry, CancellationToken cancellationToken = default) { // Lock on the business key (order ID) to prevent concurrent processing // of the same order across all queue entries string lockKey = $"order:{queueEntry.Value.OrderId}"; return _lockProvider.TryAcquireAsync(lockKey, TimeSpan.FromMinutes(5), cancellationToken); } protected override async Task ProcessQueueEntryAsync( QueueEntryContext context) { // This will only execute if we successfully acquired the lock // Multiple queue entries for the same order will be serialized var orderId = context.QueueEntry.Value.OrderId; await _orderService.ProcessAsync(orderId, context.CancellationToken); return JobResult.Success; } } ``` **How it works:** 1. When `QueueJobBase` dequeues an entry, it calls `GetQueueEntryLockAsync` before processing 2. If the lock cannot be acquired (returns `null`), the entry is abandoned and returned to the queue 3. If the lock acquisition throws an exception (e.g., network failure), the entry is abandoned and a `JobResult.FromException` is returned 4. If the lock is acquired, processing continues and the lock is automatically released after completion 5. The lock is also used for manual renewal within `ProcessQueueEntryAsync` via `await context.QueueEntry.RenewLockAsync()` ::: tip Lock Provider Selection Use a distributed lock provider (e.g., `CacheLockProvider` with Redis) in production to coordinate across multiple instances. For single-instance scenarios, `CacheLockProvider` with `InMemoryCacheClient` is sufficient. ::: ## Processing Patterns ::: tip Recommended Approach For production applications, use `QueueJobBase` with `Foundatio.Extensions.Hosting` for reliable, automatic background processing. See [Jobs - Queue Processor Jobs](/guide/jobs#queue-processor-jobs) for details. The patterns below are for advanced scenarios or custom integrations. ::: ### Simple Processing Loop ```csharp while (!cancellationToken.IsCancellationRequested) { var entry = await queue.DequeueAsync(cancellationToken); if (entry == null) continue; try { await ProcessAsync(entry.Value); await entry.CompleteAsync(); } catch (Exception ex) { _logger.LogError(ex, "Failed to process {Id}", entry.Value.Id); await entry.AbandonAsync(); } } ``` ### Using StartWorkingAsync Simplified background processing: ```csharp // Start processing in background await queue.StartWorkingAsync( async (entry, ct) => { await ProcessAsync(entry.Value); }, autoComplete: true, // Automatically complete on success cancellationToken ); ``` ## Queue Entry Options Configure enqueue behavior: ```csharp await queue.EnqueueAsync(new WorkItem { Id = 1 }, new QueueEntryOptions { UniqueId = "unique-id", // Dedupe by ID CorrelationId = "request-123", // For tracing DeliveryDelay = TimeSpan.FromMinutes(5), // Delayed delivery Properties = new Dictionary { ["priority"] = "high" } }); ``` ## Queue Events Subscribe to queue lifecycle events: ```csharp var queue = new InMemoryQueue(); queue.Enqueuing.AddHandler((sender, args) => { _logger.LogInformation("Enqueuing: {Data}", args.Data); return Task.CompletedTask; }); queue.Enqueued.AddHandler((sender, args) => { _logger.LogInformation("Enqueued: {Id}", args.Entry.Id); return Task.CompletedTask; }); queue.Dequeued.AddHandler((sender, args) => { _logger.LogInformation("Dequeued: {Id}", args.Entry.Id); return Task.CompletedTask; }); queue.Completed.AddHandler((sender, args) => { _logger.LogInformation("Completed: {Id}", args.Entry.Id); return Task.CompletedTask; }); queue.Abandoned.AddHandler((sender, args) => { _logger.LogWarning("Abandoned: {Id}", args.Entry.Id); return Task.CompletedTask; }); queue.QueueDeleted.AddHandler((sender, args) => { _logger.LogInformation("Queue deleted"); return Task.CompletedTask; }); ``` ## Queue Behaviors Extend queue functionality with behaviors. Behaviors hook into queue events to add cross-cutting concerns like logging, metrics, or deduplication. ### Creating Custom Behaviors ```csharp public class LoggingQueueBehavior : QueueBehaviorBase where T : class { private readonly ILogger _logger; public LoggingQueueBehavior(ILogger logger) => _logger = logger; protected override Task OnEnqueued(object sender, EnqueuedEventArgs args) { _logger.LogInformation("Enqueued {Id}", args.Entry.Id); return Task.CompletedTask; } protected override Task OnDequeued(object sender, DequeuedEventArgs args) { _logger.LogInformation("Dequeued {Id}", args.Entry.Id); return Task.CompletedTask; } protected override Task OnCompleted(object sender, CompletedEventArgs args) { _logger.LogInformation("Completed {Id} in {Duration}ms", args.Entry.Id, args.Entry.ProcessingTime.TotalMilliseconds); return Task.CompletedTask; } protected override Task OnAbandoned(object sender, AbandonedEventArgs args) { _logger.LogWarning("Abandoned {Id}, attempt {Attempt}", args.Entry.Id, args.Entry.Attempts); return Task.CompletedTask; } protected override Task OnQueueDeleted(object sender, QueueDeletedEventArgs args) { _logger.LogInformation("Queue deleted"); return Task.CompletedTask; } } // Attach to queue queue.AttachBehavior(new LoggingQueueBehavior(logger)); ``` ### Built-in: Duplicate Detection Behavior Foundatio includes `DuplicateDetectionQueueBehavior` to prevent duplicate messages from being enqueued. This is useful for scenarios where the same work item might be submitted multiple times. ```csharp // Your message must implement IHaveUniqueIdentifier public class OrderWorkItem : IHaveUniqueIdentifier { public int OrderId { get; set; } public string UniqueIdentifier => $"order:{OrderId}"; } // Attach the behavior var cache = new InMemoryCacheClient(); queue.AttachBehavior(new DuplicateDetectionQueueBehavior( cache, loggerFactory, detectionWindow: TimeSpan.FromMinutes(10) // How long to remember seen IDs )); // Duplicates are automatically discarded await queue.EnqueueAsync(new OrderWorkItem { OrderId = 123 }); // ✅ Enqueued await queue.EnqueueAsync(new OrderWorkItem { OrderId = 123 }); // ❌ Discarded (duplicate) await queue.EnqueueAsync(new OrderWorkItem { OrderId = 456 }); // ✅ Enqueued ``` **How it works:** 1. On enqueue, the behavior checks if the `UniqueIdentifier` exists in the cache 2. If found, the message is discarded (not enqueued) 3. If not found, the identifier is cached with the specified TTL 4. On dequeue, the identifier is removed from the cache (allowing re-submission) ### Behavior Attachment Rules Each behavior instance can only be attached to a single queue. Attempting to attach the same behavior instance to multiple queues or attaching it twice to the same queue throws a `QueueException`. This prevents subtle bugs where event handlers could fire against the wrong queue reference. ```csharp // ✅ Correct: separate instances for each queue queue1.AttachBehavior(new LoggingQueueBehavior(logger)); queue2.AttachBehavior(new LoggingQueueBehavior(logger)); // ❌ Throws QueueException: same instance attached twice var behavior = new LoggingQueueBehavior(logger); queue1.AttachBehavior(behavior); queue2.AttachBehavior(behavior); // throws QueueException ``` ### Attaching Multiple Behaviors You can attach multiple different behavior instances to a single queue: ```csharp var queue = new InMemoryQueue(o => o .Behaviors( new LoggingQueueBehavior(logger), new DuplicateDetectionQueueBehavior(cache, loggerFactory), new MetricsQueueBehavior(metrics) )); ``` ## Queue Statistics Monitor queue health: ```csharp var stats = await queue.GetQueueStatsAsync(); Console.WriteLine($"Queued: {stats.Queued}"); Console.WriteLine($"Working: {stats.Working}"); Console.WriteLine($"Dead Letter: {stats.Deadletter}"); Console.WriteLine($"Enqueued: {stats.Enqueued}"); Console.WriteLine($"Dequeued: {stats.Dequeued}"); Console.WriteLine($"Completed: {stats.Completed}"); Console.WriteLine($"Abandoned: {stats.Abandoned}"); Console.WriteLine($"Errors: {stats.Errors}"); Console.WriteLine($"Timeouts: {stats.Timeouts}"); ``` ## Dead Letter Queue Handle failed messages that have exceeded the retry limit: ```csharp // Get dead letter items var deadLetters = await queue.GetDeadletterItemsAsync(); foreach (var item in deadLetters) { _logger.LogWarning("Dead letter: {Id}", item.Id); // Optionally re-queue for retry await queue.EnqueueAsync(item); } ``` ### When Messages Go to Dead Letter Messages are moved to the dead letter queue when: 1. The message has been abandoned more times than the configured `Retries` count 2. Processing repeatedly fails and the retry limit is exhausted ### Monitoring Dead Letters ```csharp var stats = await queue.GetQueueStatsAsync(); if (stats.Deadletter > 0) { _logger.LogWarning("Dead letter queue has {Count} items", stats.Deadletter); // Alert operations team, trigger investigation } ``` ### Poison Message Handling When a message cannot be deserialized during dequeue (e.g., corrupted data, schema changes, or serializer misconfiguration), all Foundatio queue implementations handle it gracefully: 1. **The deserialization exception is caught** and logged as a warning with the message ID and current attempt count 2. **The message is abandoned** through the normal `AbandonAsync` flow, which increments the attempt counter and applies retry delay/backoff 3. **`null` is returned** from `DequeueAsync`, so the consumer never sees the undeserializable message 4. **After exhausting retries**, the message is moved to the dead letter queue through the standard dead-lettering path This approach gives operators a window to fix transient issues (such as a missing `JsonConverter` or incorrect serializer configuration) before messages are permanently dead-lettered. If the serializer configuration is corrected and redeployed before retries are exhausted, the message will deserialize successfully on the next attempt. ```text Dequeue → Deserialize fails → Abandon (attempt incremented) → Still has retries? → Re-queued with backoff delay → Retries exhausted? → Moved to dead letter queue ``` ## Retry Policies All Foundatio queue implementations share common retry behavior configured via `SharedQueueOptions`: | Option | Default | Description | |--------|---------|-------------| | `Retries` | 2 | Maximum number of retry attempts before dead-lettering | | `WorkItemTimeout` | 5 minutes | How long a worker can hold a message before it's considered abandoned | ### WorkItemTimeout Best Practices The `WorkItemTimeout` determines how long a dequeued entry stays locked before being considered abandoned and returned to the queue for retry. Setting this value correctly is critical for reliable queue processing. **Guidelines for setting `WorkItemTimeout`:** ```csharp var queue = new RedisQueue(o => { // For predictable workloads: typical duration + padding // Example: If processing takes 2 minutes, set to 4-5 minutes o.WorkItemTimeout = TimeSpan.FromMinutes(5); // For variable workloads: maximum expected duration + buffer // Example: If processing can take up to 10 minutes, set to 15 minutes o.WorkItemTimeout = TimeSpan.FromMinutes(15); }); ``` **Sizing recommendations:** * **Fast operations (< 30 seconds)**: Set to 1-2 minutes to allow for retries without long delays * **Standard operations (1-5 minutes)**: Set to 2x your average processing time (e.g., 3 minutes avg → 6 minute timeout) * **Long operations (> 5 minutes)**: Set to 1.5x your maximum expected time, but consider using manual lock renewal if highly variable * **Always include padding**: Account for network latency, temporary slowdowns, and system load **What happens when timeout expires:** 1. The queue entry lock is released 2. Another worker can pick up the same entry 3. The original worker may still be processing (potentially duplicate work) 4. Entry's `Attempts` counter increments 5. After `Retries` attempts, the entry moves to the dead letter queue ::: warning Timeout Too Short If `WorkItemTimeout` is too short, entries will be re-queued before processing completes, leading to duplicate processing attempts and wasted resources. ::: ::: tip Monitoring and Adjustment Monitor your queue processing times and adjust `WorkItemTimeout` based on actual metrics. Use Application Insights, logging, or custom telemetry to track processing duration over time. ::: ### InMemoryQueue Retry Options The in-memory queue provides additional retry configuration: ```csharp var queue = new InMemoryQueue(o => { o.Retries = 3; // Max retry attempts o.RetryDelay = TimeSpan.FromMinutes(1); // Base delay between retries o.RetryMultipliers = new[] { 1, 3, 5, 10 }; // Exponential backoff multipliers }); ``` **Retry delay calculation:** `RetryDelay × RetryMultipliers[attempt - 1]` For example, with defaults: * 1st retry: 1 minute × 1 = 1 minute * 2nd retry: 1 minute × 3 = 3 minutes * 3rd retry: 1 minute × 5 = 5 minutes * 4th+ retry: 1 minute × 10 = 10 minutes ### Provider-Specific Retry Behavior | Provider | Retry Mechanism | Dead Letter Support | |----------|-----------------|---------------------| | InMemoryQueue | Built-in with configurable backoff | In-memory dead letter queue | | RedisQueue | Built-in with configurable backoff | Redis-backed dead letter queue | | AzureServiceBusQueue | Native Service Bus retries | Native DLQ with message metadata | | AzureStorageQueue | Built-in retries | Poison message queue | | SQSQueue | Native SQS retries | Native DLQ (requires configuration) | ## Message Size Limits Different queue providers have different message size limits. Understanding these limits is crucial for designing your message contracts. | Provider | Max Message Size | Notes | |----------|------------------|-------| | InMemoryQueue | Limited by available memory | No practical limit | | RedisQueue | 512 MB (Redis limit) | Recommended: < 1 MB for performance | | AzureServiceBusQueue | 256 KB (Standard) / 100 MB (Premium) | Use claim check pattern for large payloads | | AzureStorageQueue | 64 KB | Base64 encoded, effective ~48 KB | | SQSQueue | 256 KB | Use S3 for larger messages | ### Best Practice: Keep Messages Small ```csharp // ✅ Good: Small message with reference public record ProcessImageWorkItem { public required string ImageBlobPath { get; init; } // Reference to storage public required string OutputPath { get; init; } public required ImageProcessingOptions Options { get; init; } } // ❌ Bad: Large payload in message public record ProcessImageWorkItem { public required byte[] ImageData { get; init; } // Could be megabytes! public required ImageProcessingOptions Options { get; init; } } ``` ### Claim Check Pattern for Large Payloads When you need to process large data, store it externally and pass a reference: ```csharp // Store large data in blob storage var blobPath = $"work-items/{Guid.NewGuid()}.json"; await fileStorage.SaveObjectAsync(blobPath, largePayload); // Enqueue reference only await queue.EnqueueAsync(new WorkItem { PayloadPath = blobPath, PayloadSize = largePayload.Length }); // In worker: retrieve the payload var entry = await queue.DequeueAsync(); var payload = await fileStorage.GetObjectAsync(entry.Value.PayloadPath); await ProcessAsync(payload); await entry.CompleteAsync(); // Clean up blob after processing await fileStorage.DeleteFileAsync(entry.Value.PayloadPath); ``` ## Dependency Injection ### Basic Registration ```csharp // In-memory (development) services.AddSingleton>(sp => new InMemoryQueue()); // Redis (production) services.AddSingleton>(sp => new RedisQueue(o => { o.ConnectionMultiplexer = sp.GetRequiredService(); o.Name = "work-items"; })); ``` ::: tip Automatic Queue Processing For automatic background processing of queue items, use `QueueJobBase` with `Foundatio.Extensions.Hosting`. See [Jobs - Queue Processor Jobs](/guide/jobs#queue-processor-jobs) for details. ```csharp // Register queue and processor job services.AddSingleton>(sp => new InMemoryQueue()); services.AddJob(); // Automatically processes queue items ``` ### Multiple Queues ```csharp services.AddSingleton>(sp => new InMemoryQueue(o => o.Name = "orders")); services.AddSingleton>(sp => new InMemoryQueue(o => o.Name = "emails")); ``` ## Queue Exceptions Queue operations throw `QueueException` for queue-specific error conditions. This provides a consistent, predictable exception type across all queue implementations (in-memory, Redis, Azure, AWS, etc.). ```csharp using Foundatio.Queues; try { // Attempting to reuse a behavior instance throws QueueException var behavior = new LoggingQueueBehavior(logger); queue1.AttachBehavior(behavior); queue2.AttachBehavior(behavior); // throws QueueException } catch (QueueException ex) { logger.LogError(ex, "Queue operation failed: {Message}", ex.Message); } ``` ## Cancellation Token Behavior Understanding how cancellation tokens are handled internally is important for building reliable queue consumers. ### Resource Creation Uses Disposal Token When you call `EnqueueAsync`, `DequeueAsync`, or `GetDeadletterItemsAsync`, the queue may need to create infrastructure (e.g., SQS queues, Azure Service Bus queues, Redis streams). These setup operations use an internal disposal token — **not** the caller's cancellation token. This means: * **Queue creation only aborts when the queue is disposed**, never because a single caller cancelled their operation. * A cancelled `DequeueAsync` call (e.g., from a zero timeout) will not prevent queue creation from completing. * Multiple concurrent callers cannot interfere with each other's setup. ### Linked Cancellation for Operations The caller's cancellation token is combined with the disposal token into a linked token for the actual operation (dequeue, deadletter retrieval, etc.). This means: * Operations cancel when **either** the caller cancels **or** the queue is disposed. * Graceful shutdown via `Dispose()` cancels all in-flight operations promptly. ```csharp // This will never prevent queue creation, even though it times out immediately var entry = await queue.DequeueAsync(TimeSpan.Zero); // The cancellation token only affects the dequeue wait, not infrastructure setup using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var entry = await queue.DequeueAsync(cts.Token); ``` ### For Implementation Authors If you are writing a custom `IQueue` implementation by extending `QueueBase`: * **`EnsureQueueCreatedAsync`** always receives `DisposedCancellationToken`. Use it for all setup operations (lock acquisition, API calls, etc.). * **`DequeueImplAsync`** receives a linked token (caller + disposal). Respect it for the wait/poll operation. * **`EnqueueImplAsync`** does not receive a cancellation token — keep enqueue fast and non-blocking. ## Best Practices ### 1. Proper Resource Disposal Queues implement `IDisposable` and should be properly disposed: ```csharp // ✅ Good: Using statement for short-lived queues await using var queue = new InMemoryQueue(); await queue.EnqueueAsync(new WorkItem { Id = 1 }); // ✅ Good: DI container manages lifetime services.AddSingleton>(sp => new InMemoryQueue()); // ❌ Bad: Not disposing var queue = new InMemoryQueue(); // ... use queue // Queue is never disposed, resources leak ``` ### 2. Use Typed Messages ```csharp // ✅ Good: Typed, versioned messages public record OrderWorkItem { public int Version { get; init; } = 1; public required int OrderId { get; init; } public required DateTime CreatedAt { get; init; } } // ❌ Bad: Generic, untyped public class WorkItem { public object Data { get; set; } } ``` ### 2. Handle Idempotency ```csharp var entry = await queue.DequeueAsync(); if (entry is null) return; // Check if already processed if (await _processedIds.ContainsAsync(entry.Value.Id)) { await entry.CompleteAsync(); return; } // Process await ProcessAsync(entry.Value); // Mark as processed await _processedIds.AddAsync(entry.Value.Id); await entry.CompleteAsync(); ``` ### 3. Set Appropriate Timeouts ```csharp var queue = new RedisQueue(o => { o.WorkItemTimeout = TimeSpan.FromMinutes(5); // How long to process o.RetryDelay = TimeSpan.FromSeconds(30); // Delay before retry o.Retries = 3; // Max retries }); ``` ### 4. Monitor Queue Depth ```csharp var stats = await queue.GetQueueStatsAsync(); if (stats.Queued > 1000) { _logger.LogWarning("Queue depth is high: {Depth}", stats.Queued); // Consider scaling workers } ``` ### 5. Use Delayed Delivery for Scheduling ```csharp // Schedule for later await queue.EnqueueAsync(reminder, new QueueEntryOptions { DeliveryDelay = TimeSpan.FromHours(24) }); ``` ## Queue Name vs Queue ID Every queue has two distinct identifiers that serve different purposes: | Property | Purpose | Stable across restarts? | Shared across processes? | |----------|---------|------------------------|--------------------------| | `Name` | Identifies the queue in the **backing store** (Redis key prefix, SQS queue name, etc.) | ✅ Yes | ✅ Yes — two processes with the same `Name` share the same data | | `QueueId` | Runtime **instance identifier** used only for logging and diagnostics | ❌ No (random suffix by default) | N/A — never used for data routing | **`Name`** is what controls which data is read and written. All distributed queue implementations (Redis, SQS, Azure Service Bus, Azure Storage) route messages based on `Name`, not `QueueId`. Two processes or application restarts using the same `Name` will naturally share queue data and continue from where the other left off. **`QueueId`** exists purely so that multiple queue instances within the same process (e.g., priority queues or keyed queues) produce distinguishable log output. It has no effect on the backing store. ::: tip Sharing queues across processes If you want multiple processes (or application restarts) to share a queue, just ensure they all configure the same `Name` value. The default is `typeof(T).Name` (the message type name), so it is consistent by default as long as you use the same message type. ```csharp // Both processes use the same Name → they share the same queue in Redis var queue = new RedisQueue(o => { o.ConnectionMultiplexer = redis; o.Name = "work-items"; // This is the stable backing-store identifier }); ``` ::: ::: info InMemoryQueue `InMemoryQueue` is an in-process implementation only. It cannot share data across processes regardless of `Name` or `QueueId`. Use a distributed implementation (Redis, SQS, Azure) for cross-process sharing. ::: ## Next Steps * [Jobs](./jobs) - Queue processor jobs for automatic background processing with `QueueJobBase` * [Messaging](./messaging) - Pub/sub for event-driven patterns * [Locks](./locks) - Coordinate queue processing across instances * [Serialization](./serialization) - Serializer configuration and performance --- --- url: /guide/implementations/redis.md --- # Redis Implementation Foundatio provides Redis implementations for caching, queues, messaging, locks, and file storage. Redis enables distributed scenarios across multiple processes and servers. ## Overview | Implementation | Interface | Package | |----------------|-----------|---------| | `RedisCacheClient` | `ICacheClient` | Foundatio.Redis | | `RedisHybridCacheClient` | `ICacheClient` | Foundatio.Redis | | `RedisQueue` | `IQueue` | Foundatio.Redis | | `RedisMessageBus` | `IMessageBus` | Foundatio.Redis | | `RedisFileStorage` | `IFileStorage` | Foundatio.Redis | | `CacheLockProvider` | `ILockProvider` | Foundatio (with Redis cache) | ## Installation ```bash dotnet add package Foundatio.Redis ``` ## Connection Setup ### Basic Connection ```csharp using StackExchange.Redis; var redis = await ConnectionMultiplexer.ConnectAsync("localhost:6379"); ``` ### Production Connection ```csharp var options = new ConfigurationOptions { EndPoints = { "redis-primary:6379", "redis-replica:6379" }, Password = "your-password", Ssl = true, AbortOnConnectFail = false, ConnectRetry = 5, ConnectTimeout = 5000, SyncTimeout = 5000, AsyncTimeout = 5000 }; var redis = await ConnectionMultiplexer.ConnectAsync(options); ``` ### Connection String Format ```txt redis:6379,password=secret,ssl=true,abortConnect=false ``` ## RedisCacheClient A distributed cache backed by Redis. ### Basic Usage ```csharp using Foundatio.Caching; var cache = new RedisCacheClient(options => { options.ConnectionMultiplexer = redis; }); // Store and retrieve await cache.SetAsync("user:123", user); var cachedUser = await cache.GetAsync("user:123"); // With expiration await cache.SetAsync("session:abc", session, TimeSpan.FromHours(1)); ``` ### Configuration Options ```csharp var cache = new RedisCacheClient(options => { options.ConnectionMultiplexer = redis; options.LoggerFactory = loggerFactory; options.Serializer = new SystemTextJsonSerializer(); options.ReadMode = CommandFlags.PreferReplica; // Route reads to replicas }); ``` ### Advanced Operations ```csharp // Increment/Decrement await cache.IncrementAsync("counter", 1); await cache.IncrementAsync("views", 1.5); // Set if not exists var added = await cache.AddAsync("lock-key", "locked", TimeSpan.FromSeconds(30)); // Batch operations await cache.SetAllAsync(new Dictionary { ["user:1"] = user1, ["user:2"] = user2 }); var users = await cache.GetAllAsync(new[] { "user:1", "user:2" }); ``` ## RedisHybridCacheClient Combines Redis (L2 distributed cache) with a local in-memory cache (L1) for optimal performance. This implements the industry-standard L1/L2 caching architecture. ### How It Works **Read Flow:** ```txt ┌─────────┐ ┌──────────────┐ ┌──────────────┐ │ Request │────▶│ L1 Cache │────▶│ L2 Cache │ │ │ │ (In-Memory) │ │ (Redis) │ └─────────┘ └──────────────┘ └──────────────┘ │ │ ▼ ▼ Cache Hit? Cache Hit? │ │ Yes: Return Yes: Store in L1 Then: Return ``` **Write Flow (Distributed-First):** ```txt ┌──────────────────┐ │ Write Operation │ └────────┬─────────┘ │ ▼ ┌──────────────┐ │ L2 Cache │ ◀── Write to L2 first (source of truth) │ (Redis) │ └──────┬───────┘ │ │ Success? │ ┌─────┴─────┐ │ │ ▼ ▼ Yes No │ │ ▼ │ ┌──────────────┐ │ │ L1 Cache │ │ │ (In-Memory) │ │ └──────────────┘ │ │ │ └─────┬─────┘ │ ▼ ┌──────────────┐ │ Message Bus │───▶ Other Instances: │ (Publish) │ Clear SPECIFIC └──────────────┘ Keys Only ``` * On **read**: Check L1 (local) first, then L2 (Redis). Store in L1 if found in L2. * On **write**: Write to L2 (Redis) first, then update L1 only on success, then publish invalidation so other instances clear **only the affected keys** from their L1 cache. * **Prefix removal**: `RemoveByPrefixAsync("user:")` clears all `user:*` keys on all instances. * **Full flush**: `RemoveAllAsync()` with no keys clears entire L1 cache on all instances. ::: warning Shared Message Bus Topic By default, all instances share the same Redis pub/sub topic for invalidation. In high-write scenarios, consider using separate topics per feature area. See the [Caching Guide](/guide/caching#hybrid-cache-invalidation-traffic) for details. ::: ### Basic Usage ```csharp var hybridCache = new RedisHybridCacheClient( redisConfig => redisConfig.ConnectionMultiplexer(redis).LoggerFactory(loggerFactory), localConfig => localConfig.MaxItems(1000) ); ``` ### Cache Invalidation The hybrid cache uses Redis pub/sub to invalidate local caches across all instances: ```csharp // When you update a value, all instances are notified await hybridCache.SetAsync("config:app", newConfig); // Other instances automatically invalidate their local copy ``` ### Benefits * **Reduced Latency**: Local cache hits avoid network round-trips * **Reduced Load**: Fewer Redis operations * **Automatic Sync**: Pub/sub keeps caches consistent * **Configurable Size**: Control local cache memory usage ## RedisQueue A reliable distributed queue with visibility timeout and dead letter support. ### Basic Usage ```csharp using Foundatio.Queues; var queue = new RedisQueue(options => { options.ConnectionMultiplexer = redis; options.Name = "work-items"; }); // Enqueue await queue.EnqueueAsync(new WorkItem { Id = 1 }); // Dequeue and process var entry = await queue.DequeueAsync(); if (entry != null) { try { await ProcessAsync(entry.Value); await entry.CompleteAsync(); } catch { await entry.AbandonAsync(); } } ``` ### Configuration Options ```csharp var queue = new RedisQueue(options => { options.ConnectionMultiplexer = redis; options.Name = "work-items"; // Visibility timeout options.WorkItemTimeout = TimeSpan.FromMinutes(5); // Retry settings options.Retries = 3; options.RetryDelay = TimeSpan.FromSeconds(30); // Dead letter settings options.DeadLetterTimeToLive = TimeSpan.FromDays(1); options.DeadLetterMaxItems = 100; // Run maintenance (cleanup dead letters) options.RunMaintenanceTasks = true; // Route reads to replicas (see Read Routing section for caveats) options.ReadMode = CommandFlags.PreferReplica; options.LoggerFactory = loggerFactory; }); ``` ### Queue Features ```csharp // Get queue statistics var stats = await queue.GetQueueStatsAsync(); Console.WriteLine($"Queued: {stats.Queued}"); Console.WriteLine($"Working: {stats.Working}"); Console.WriteLine($"Dead Letter: {stats.Deadletter}"); // Process continuously await queue.StartWorkingAsync(async (entry, token) => { await ProcessWorkItemAsync(entry.Value); }); ``` ## RedisMessageBus A pub/sub message bus using Redis for cross-process communication. ### Basic Usage ```csharp using Foundatio.Messaging; var messageBus = new RedisMessageBus(options => { options.Subscriber = redis.GetSubscriber(); }); // Subscribe await messageBus.SubscribeAsync(async message => { await HandleOrderCreatedAsync(message); }); // Publish await messageBus.PublishAsync(new OrderCreatedEvent { OrderId = "123" }); ``` ### Configuration Options ```csharp var messageBus = new RedisMessageBus(options => { options.Subscriber = redis.GetSubscriber(); options.Topic = "myapp"; // Channel prefix options.LoggerFactory = loggerFactory; options.Serializer = serializer; }); ``` ### Topic-Based Routing ```csharp // Messages are published to channels based on type // e.g., "myapp:OrderCreatedEvent" // All instances subscribed to this type receive the message await messageBus.SubscribeAsync(HandleOrder); ``` ## RedisFileStorage Store files in Redis (suitable for small files and caching scenarios). ### Basic Usage ```csharp using Foundatio.Storage; var storage = new RedisFileStorage(options => { options.ConnectionMultiplexer = redis; }); // Save file await storage.SaveFileAsync("config/settings.json", settingsJson); // Read file var content = await storage.GetFileContentsAsync("config/settings.json"); ``` ### Configuration Options ```csharp var storage = new RedisFileStorage(options => { options.ConnectionMultiplexer = redis; options.LoggerFactory = loggerFactory; options.Serializer = serializer; options.ReadMode = CommandFlags.PreferReplica; // Route reads to replicas }); ``` ::: warning Redis file storage is best for small files or caching scenarios. For large files, consider Azure Blob Storage or S3. ::: ## Distributed Locks with Redis Use `CacheLockProvider` with Redis for distributed locking. ### Basic Usage ```csharp using Foundatio.Lock; var locker = new CacheLockProvider( cache: redisCacheClient, messageBus: redisMessageBus ); await using var lockHandle = await locker.AcquireAsync( resource: "order:123", timeUntilExpires: TimeSpan.FromMinutes(5), cancellationToken: token ); if (lockHandle != null) { // Exclusive access to order:123 await ProcessOrderAsync("123"); } ``` ### Lock Patterns ```csharp // Try to acquire, fail fast var lockHandle = await locker.AcquireAsync("resource"); if (lockHandle == null) { throw new ResourceBusyException(); } // Wait for lock with timeout var lockHandle = await locker.AcquireAsync( resource: "resource", acquireTimeout: TimeSpan.FromSeconds(30) ); // Extend lock duration await lockHandle.RenewAsync(TimeSpan.FromMinutes(5)); ``` ## Complete Redis Setup ### Service Registration ```csharp public static IServiceCollection AddFoundatioRedis( this IServiceCollection services, string connectionString) { // Connection services.AddSingleton(sp => ConnectionMultiplexer.Connect(connectionString)); // Cache (Hybrid for best performance) services.AddSingleton(sp => new RedisHybridCacheClient( redisConfig => redisConfig .ConnectionMultiplexer(sp.GetRequiredService()) .LoggerFactory(sp.GetRequiredService()), localConfig => localConfig.MaxItems(1000) )); // Message Bus services.AddSingleton(sp => new RedisMessageBus(options => { options.Subscriber = sp .GetRequiredService() .GetSubscriber(); options.LoggerFactory = sp.GetRequiredService(); })); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); // Lock Provider services.AddSingleton(sp => new CacheLockProvider( sp.GetRequiredService(), sp.GetRequiredService())); return services; } // Add queue public static IServiceCollection AddRedisQueue( this IServiceCollection services, string name) where T : class { services.AddSingleton>(sp => new RedisQueue(options => { options.ConnectionMultiplexer = sp.GetRequiredService(); options.Name = name; options.LoggerFactory = sp.GetRequiredService(); })); return services; } ``` ### Usage ```csharp var builder = WebApplication.CreateBuilder(args); var redisConnection = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379"; builder.Services.AddFoundatioRedis(redisConnection); builder.Services.AddRedisQueue("work-items"); builder.Services.AddRedisQueue("emails"); ``` ## Production Considerations ### Connection Resilience ```csharp var options = new ConfigurationOptions { EndPoints = { "redis:6379" }, AbortOnConnectFail = false, // Don't throw on startup ConnectRetry = 5, ReconnectRetryPolicy = new ExponentialRetry(5000) }; ``` ### Health Checks ```csharp builder.Services.AddHealthChecks() .AddRedis(redisConnection, name: "redis"); ``` ### Monitoring ```csharp // Get server info var server = redis.GetServer("redis:6379"); var info = await server.InfoAsync(); // Memory usage var memory = info.FirstOrDefault(g => g.Key == "memory"); ``` ### Cluster Support ```csharp var options = new ConfigurationOptions { EndPoints = { "redis-1:6379", "redis-2:6379", "redis-3:6379" } }; // StackExchange.Redis handles cluster topology automatically var redis = await ConnectionMultiplexer.ConnectAsync(options); ``` ## Best Practices ### 1. Connection Management ```csharp // ✅ Singleton connection services.AddSingleton( ConnectionMultiplexer.Connect("redis:6379")); // ❌ Creating new connections var redis = ConnectionMultiplexer.Connect("redis:6379"); ``` ### 2. Key Naming ```csharp // ✅ Hierarchical, descriptive keys await cache.SetAsync("user:123:profile", profile); await cache.SetAsync("order:456:items", items); // ❌ Flat, ambiguous keys await cache.SetAsync("123", profile); ``` ### 3. Serialization ```csharp // Use efficient serializers for large objects var serializer = new MessagePackSerializer(); var cache = new RedisCacheClient(o => o.Serializer = serializer); ``` ### 4. TTL Strategy ```csharp // Always set expiration for cached data await cache.SetAsync("data", value, TimeSpan.FromHours(1)); // Use sliding expiration for frequently accessed data await cache.SetAsync("session", data, expiresIn: TimeSpan.FromMinutes(30)); ``` ## Read Routing (Replica Reads) All Redis providers support a `ReadMode` option that controls how read operations are routed in a master-replica topology. By default, reads go to the master node (`CommandFlags.None`). Set `ReadMode` to `CommandFlags.PreferReplica` to distribute reads to replica nodes, reducing load on the master and improving read throughput. ### Configuration ```csharp using StackExchange.Redis; // Enable replica reads on cache var cache = new RedisCacheClient(o => o .ConnectionMultiplexer(redis) .ReadMode(CommandFlags.PreferReplica)); // Enable replica reads on queue var queue = new RedisQueue(o => o .ConnectionMultiplexer(redis) .ReadMode(CommandFlags.PreferReplica)); // Enable replica reads on file storage var storage = new RedisFileStorage(o => o .ConnectionMultiplexer(redis) .ReadMode(CommandFlags.PreferReplica)); ``` `PreferReplica` is safe on single-node deployments -- it falls back to the master when no replica exists. Write operations always go to the master regardless of this setting. Distributed locks are not affected (all lock operations use writes or Lua scripts on the master). ### ReadMode Values | Value | Behavior | Use case | |-------|----------|----------| | `CommandFlags.None` (default) | Read from master | Backward compatible; strict consistency | | `CommandFlags.PreferReplica` | Read from replica if available, fall back to master | Recommended for master-replica topologies | | `CommandFlags.DemandReplica` | Replica only; error if none available | Dedicated read-scaling scenarios | | `CommandFlags.DemandMaster` | Master only; error if unavailable | Critical path operations | ### Operation Routing by Provider | Provider | Operation | Routing | |----------|-----------|---------| | **RedisCacheClient** | `GetAsync`, `GetAllAsync`, `GetListAsync` | Via ReadMode | | | `ExistsAsync`, `GetExpirationAsync` | Via ReadMode | | | `SetAsync`, `RemoveAsync`, `IncrementAsync` | Always master | | | `GetAllExpirationAsync` | Always master (Lua script) | | | Lua scripts (`SetIfHigher`, `ReplaceIfEqual`, etc.) | Always master | | **RedisQueue** | Internal payload/metadata reads | Via ReadMode | | | Enqueue, dequeue, complete, abandon | Always master | | | Maintenance (work list, wait list scans) | Always master | | **RedisFileStorage** | `GetFileStreamAsync`, `GetFileInfoAsync`, `ExistsAsync` | Via ReadMode | | | `GetFileListAsync` | Via ReadMode | | | `SaveFileAsync`, `DeleteFileAsync` | Always master | | **RedisMessageBus** | Pub/sub | N/A (not routable) | ### Replication Lag Considerations ::: warning Redis/Valkey replication is asynchronous. When using `PreferReplica`, reads may return stale data during the replication lag window (typically sub-millisecond on AWS ElastiCache, but variable under load). Review the scenarios below before enabling replica reads. ::: | Scenario | Risk | Impact | |----------|------|--------| | **Queue: dequeue payload read** | **High** | After enqueue writes a payload, a dequeue on another process reads it back. If the replica hasn't replicated yet, the payload is `null`, the item is removed from the work list, and the message is silently lost. | | **Queue: abandon retry count** | Medium | The attempts counter is incremented on master, then read back during abandon. A stale replica read returns an old count, giving the item one extra retry before dead-lettering. | | **Queue: maintenance renewal check** | Medium | Lock renewal writes a timestamp to master. Maintenance reads it to check timeout. A stale read may auto-abandon an item that was just renewed, causing spurious re-processing. | | **Queue: maintenance wait time** | Low | Wait times for retry delays are read from cache. A stale read makes an item wait slightly longer before retry. | | **File storage: rename/copy** | **None** | `RenameFileAsync` and `CopyFileAsync` always read from master regardless of `ReadMode` to prevent data loss. | | **Cache: sorted set expiration** | **None** | `SetListExpirationAsync` always reads from master regardless of `ReadMode` since the result drives a subsequent write. | | **Distributed locks** | **None** | Lock acquire, release, and renewal all use writes or Lua scripts that execute on master. | **Per-provider guidance:** * **RedisCacheClient**: `PreferReplica` is safe for most read-heavy workloads. Risk exists only if you read a key immediately after writing it from a different process. * **RedisQueue**: Use caution. Under very high throughput, dequeue can fail to read a just-enqueued payload, causing message loss. Consider keeping `CommandFlags.None` for queues processing critical work items. * **RedisFileStorage**: Generally safe. Read-before-write flows (`RenameFileAsync`, `CopyFileAsync`) always read from master to prevent data loss. ## Next Steps * [Azure Implementation](./azure) - Azure Storage and Service Bus * [AWS Implementation](./aws) - S3 and SQS * [In-Memory Implementation](./in-memory) - Local development ## GitHub Repository * [Foundatio.Redis](https://github.com/FoundatioFx/Foundatio.Redis) - View source code and contribute --- --- url: /guide/resilience.md --- # Resilience Resilience policies provide a powerful way to handle transient failures and make your applications more robust. Foundatio's resilience system includes retry logic, circuit breakers, timeouts, and exponential backoff. ## The IResiliencePolicy Interface ```csharp public interface IResiliencePolicy { // Synchronous methods void Execute(Action action, CancellationToken cancellationToken = default); TResult Execute(Func action, CancellationToken cancellationToken = default); // Asynchronous methods ValueTask ExecuteAsync(Func action, CancellationToken cancellationToken = default); ValueTask ExecuteAsync(Func> action, CancellationToken cancellationToken = default); // State-based overloads (zero allocations) void Execute(TState state, Action action, CancellationToken cancellationToken = default); TResult Execute(TState state, Func action, CancellationToken cancellationToken = default); ValueTask ExecuteAsync(TState state, Func action, CancellationToken cancellationToken = default); ValueTask ExecuteAsync(TState state, Func> action, CancellationToken cancellationToken = default); } ``` ## Basic Usage ### Creating a Policy ```csharp using Foundatio.Resilience; var policy = new ResiliencePolicyBuilder() .WithMaxAttempts(5) .WithExponentialDelay(TimeSpan.FromSeconds(1)) .WithJitter() .Build(); ``` ### Executing with Retry (Async) ```csharp await policy.ExecuteAsync(async ct => { await SomeUnreliableOperationAsync(ct); }); ``` ### Executing with Retry (Sync) ```csharp policy.Execute(ct => { SomeUnreliableOperation(); }); ``` ### With Return Values ```csharp // Async var result = await policy.ExecuteAsync(async ct => { return await GetDataFromApiAsync(ct); }); // Sync var result = policy.Execute(ct => { return GetDataFromDatabase(); }); ``` ### Zero-Allocation Execution (State-Based) For performance-critical paths, use state-based overloads to avoid closure allocations: ```csharp // Pass state explicitly instead of capturing in a closure var userId = 42; var result = await policy.ExecuteAsync(userId, async (id, ct) => { return await GetUserAsync(id, ct); }); // Sync version policy.Execute(userId, (id, ct) => { ProcessUser(id); }); ``` ## ResiliencePolicyBuilder ### Retry Configuration ```csharp var policy = new ResiliencePolicyBuilder() // Maximum number of attempts (default: 3) .WithMaxAttempts(5) // Fixed delay between retries .WithDelay(TimeSpan.FromSeconds(2)) // Or exponential delay (doubles each retry) .WithExponentialDelay(TimeSpan.FromSeconds(1)) // Or linear delay (adds fixed amount each retry) .WithLinearDelay(TimeSpan.FromSeconds(1)) // Maximum delay cap .WithMaxDelay(TimeSpan.FromMinutes(1)) // Add randomness to prevent thundering herd .WithJitter() .Build(); ``` ### Custom Delay Function ```csharp var policy = new ResiliencePolicyBuilder() .WithMaxAttempts(5) .WithDelayFunction(attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))) .Build(); ``` ### Timeout Configuration ```csharp var policy = new ResiliencePolicyBuilder() .WithMaxAttempts(3) .WithTimeout(TimeSpan.FromSeconds(30)) // Overall timeout .Build(); ``` ### Conditional Retry ```csharp var policy = new ResiliencePolicyBuilder() .WithMaxAttempts(5) .WithExponentialDelay(TimeSpan.FromSeconds(1)) .WithShouldRetry((attempt, exception) => { // Only retry on specific exceptions return exception is HttpRequestException or TimeoutException; }) .Build(); ``` ### Unhandled Exceptions By default, `OperationCanceledException` and `BrokenCircuitException` are never retried. You can add additional exception types: ```csharp var policy = new ResiliencePolicyBuilder() .WithMaxAttempts(5) // These exceptions will be thrown immediately without retry .WithUnhandledException() .WithUnhandledException() .Build(); ``` ## Circuit Breaker Prevent cascading failures by temporarily stopping calls to failing services: ```csharp var policy = new ResiliencePolicyBuilder() .WithMaxAttempts(3) .WithCircuitBreaker(cb => cb // Open circuit after 50% failure rate .WithFailureRatio(0.5) // Need at least 10 calls before evaluating .WithMinimumCalls(10) // Keep circuit open for 1 minute .WithBreakDuration(TimeSpan.FromMinutes(1))) .Build(); ``` ### Circuit Breaker States ```txt ┌────────────────┐ │ Closed │ │ (Normal Ops) │ └───────┬────────┘ │ Failures │ All calls exceed │ pass through threshold │ │ ▼ ┌────────────────┐ ┌────▶│ Open │ │ │ (Fail Fast) │ │ └───────┬────────┘ │ │ Test call │ │ Break duration fails │ │ expires │ │ │ ▼ │ ┌────────────────┐ └─────│ Half-Open │ │ (Testing) │ └───────┬────────┘ │ │ Test call │ succeeds │ ▼ ┌────────────────┐ │ Closed │ └────────────────┘ ``` * **Closed**: Normal operation, calls pass through * **Open**: Calls fail immediately without execution * **Half-Open**: Single test call allowed to check recovery ### Checking Circuit State ```csharp var policy = new ResiliencePolicy(); policy.CircuitBreaker = new CircuitBreaker(new CircuitBreakerBuilder() .WithFailureRatio(0.5) .WithMinimumCalls(10) .WithBreakDuration(TimeSpan.FromMinutes(1))); // Check state if (policy.CircuitBreaker.State == CircuitState.Open) { _logger.LogWarning("Circuit is open - skipping operation"); return; } await policy.ExecuteAsync(async ct => { await CallExternalServiceAsync(ct); }); ``` ## ResiliencePolicy Properties ```csharp var policy = new ResiliencePolicy { // Maximum retry attempts MaxAttempts = 5, // Fixed delay between retries Delay = TimeSpan.FromSeconds(2), // Custom delay function GetDelay = ResiliencePolicy.ExponentialDelay(TimeSpan.FromSeconds(1)), // Maximum delay cap MaxDelay = TimeSpan.FromMinutes(1), // Add jitter to delays UseJitter = true, // Overall timeout Timeout = TimeSpan.FromMinutes(5), // Circuit breaker CircuitBreaker = new CircuitBreaker(...), // Exceptions that should not be retried UnhandledExceptions = { typeof(OperationCanceledException) }, // Custom retry logic ShouldRetry = (attempt, ex) => ex is TransientException, // Logger for retry events Logger = logger }; ``` ## Static Delay Functions Foundatio provides built-in delay functions: ```csharp // Exponential: 1s, 2s, 4s, 8s, 16s... ResiliencePolicy.ExponentialDelay(TimeSpan.FromSeconds(1)) // Linear: 1s, 2s, 3s, 4s, 5s... ResiliencePolicy.LinearDelay(TimeSpan.FromSeconds(1)) // Constant: 2s, 2s, 2s, 2s... ResiliencePolicy.ConstantDelay(TimeSpan.FromSeconds(2)) ``` ## IResiliencePolicyProvider Manage multiple named policies: ```csharp using Foundatio.Resilience; var provider = new ResiliencePolicyProviderBuilder() // Default policy for unspecified operations .WithDefaultPolicy(builder => builder .WithMaxAttempts(3) .WithExponentialDelay(TimeSpan.FromSeconds(1))) // Named policy for external APIs .WithPolicy("external-api", builder => builder .WithMaxAttempts(5) .WithCircuitBreaker() .WithTimeout(TimeSpan.FromSeconds(30))) // Named policy for database operations .WithPolicy("database", builder => builder .WithMaxAttempts(3) .WithLinearDelay(TimeSpan.FromMilliseconds(100)) .WithUnhandledException()) .Build(); // Get and use policies var apiPolicy = provider.GetPolicy("external-api"); await apiPolicy.ExecuteAsync(async ct => { await CallExternalApiAsync(ct); }); var dbPolicy = provider.GetPolicy("database"); await dbPolicy.ExecuteAsync(async ct => { await SaveToDbAsync(ct); }); ``` ## Type-Based Policies Get policies based on service type: ```csharp var provider = new ResiliencePolicyProviderBuilder() .WithPolicy(builder => builder .WithMaxAttempts(5) .WithCircuitBreaker()) .WithPolicy(builder => builder .WithMaxAttempts(3) .WithLinearDelay()) .Build(); var policy = provider.GetPolicy(); ``` ## Common Patterns ### HTTP Client with Resilience ```csharp public class ResilientHttpClient { private readonly HttpClient _client; private readonly IResiliencePolicy _policy; public ResilientHttpClient(HttpClient client) { _client = client; _policy = new ResiliencePolicyBuilder() .WithMaxAttempts(3) .WithExponentialDelay(TimeSpan.FromSeconds(1)) .WithCircuitBreaker(cb => cb .WithFailureRatio(0.5) .WithMinimumCalls(10) .WithBreakDuration(TimeSpan.FromMinutes(1))) .WithTimeout(TimeSpan.FromSeconds(30)) .WithShouldRetry((_, ex) => ex is HttpRequestException or TaskCanceledException) .Build(); } public async Task GetAsync(string url) { return await _policy.ExecuteAsync(async ct => { var response = await _client.GetAsync(url, ct); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(ct); }); } } ``` ### Database Operations ```csharp public class ResilientRepository { private readonly IResiliencePolicy _policy; public ResilientRepository() { _policy = new ResiliencePolicyBuilder() .WithMaxAttempts(3) .WithLinearDelay(TimeSpan.FromMilliseconds(100)) .WithShouldRetry((_, ex) => IsTransientDbException(ex)) .Build(); } public async Task GetUserAsync(int id) { return await _policy.ExecuteAsync(async ct => { return await _context.Users.FindAsync(id, ct); }); } private bool IsTransientDbException(Exception ex) { return ex is DbUpdateException or TimeoutException; } } ``` ### Graceful Degradation ```csharp public class CatalogService { private readonly IResiliencePolicy _policy; private readonly ICacheClient _cache; public async Task GetProductAsync(int id) { try { return await _policy.ExecuteAsync(async ct => { return await _api.GetProductAsync(id, ct); }); } catch (BrokenCircuitException) { // Fall back to cached data when circuit is open var cached = await _cache.GetAsync($"product:{id}"); if (cached.HasValue) { _logger.LogWarning("Returning cached product {Id}", id); return cached.Value; } throw; } } } ``` ### Retry with Logging ```csharp var policy = new ResiliencePolicy { MaxAttempts = 5, GetDelay = ResiliencePolicy.ExponentialDelay(TimeSpan.FromSeconds(1)), Logger = loggerFactory.CreateLogger("Resilience") }; // Logs will include attempt number, delay, and exception details await policy.ExecuteAsync(async ct => { await UnreliableOperationAsync(ct); }); ``` ## Integration with Foundatio ### Cache with Resilience ```csharp var resilientCache = new ResilientCacheClient( new RedisCacheClient(...), new ResiliencePolicyBuilder() .WithMaxAttempts(3) .WithExponentialDelay(TimeSpan.FromMilliseconds(100)) .Build() ); ``` ### Queue with Resilience ```csharp public class ResilientQueueProcessor { private readonly IQueue _queue; private readonly IResiliencePolicy _policy; public async Task ProcessAsync(WorkItem item) { await _policy.ExecuteAsync(async ct => { await DoProcessingAsync(item, ct); }); } } ``` ## Dependency Injection ### Register Policy Provider ```csharp services.AddSingleton(sp => { var logger = sp.GetRequiredService().CreateLogger("Resilience"); return new ResiliencePolicyProviderBuilder() .WithDefaultPolicy(b => b .WithMaxAttempts(3) .WithExponentialDelay(TimeSpan.FromSeconds(1)) .WithLogger(logger)) .WithPolicy("http", b => b .WithMaxAttempts(5) .WithCircuitBreaker() .WithTimeout(TimeSpan.FromSeconds(30))) .Build(); }); ``` ### Use in Services ```csharp public class MyService { private readonly IResiliencePolicy _policy; public MyService(IResiliencePolicyProvider policyProvider) { _policy = policyProvider.GetPolicy("http"); } } ``` ## Best Practices ### 1. Use Appropriate Timeouts ```csharp // Match timeout to operation type .WithTimeout(TimeSpan.FromSeconds(5)) // Fast operations .WithTimeout(TimeSpan.FromSeconds(30)) // API calls .WithTimeout(TimeSpan.FromMinutes(5)) // Long operations ``` ### 2. Configure Circuit Breaker Thresholds ```csharp .WithCircuitBreaker(cb => cb // High traffic: needs more samples .WithMinimumCalls(100) .WithFailureRatio(0.5) // Low traffic: fewer samples .WithMinimumCalls(10) .WithFailureRatio(0.3) ) ``` ### 3. Use Jitter to Prevent Thundering Herd ```csharp .WithExponentialDelay(TimeSpan.FromSeconds(1)) .WithJitter() // Adds randomness to prevent synchronized retries ``` ### 4. Handle Specific Exceptions ```csharp .WithShouldRetry((attempt, ex) => { // Only retry transient failures return ex is HttpRequestException or TimeoutException or SocketException; }) .WithUnhandledException() .WithUnhandledException() ``` > **Note:** By default, `OperationCanceledException` and `BrokenCircuitException` are never retried. Additionally, Foundatio's built-in components automatically exclude their feature-specific exceptions from retries: > > * `MessageBusBase` excludes `MessageBusException` > * `CacheLockProvider` excludes `CacheException` > > This ensures that deliberate application-level failures (like a blocked RabbitMQ connection or a cache operation error) are not wastefully retried. ### 5. Log Retry Attempts ```csharp var policy = new ResiliencePolicy { Logger = loggerFactory.CreateLogger("Resilience"), MaxAttempts = 5 }; // Automatically logs each retry attempt ``` ## Performance Foundatio's resilience implementation is optimized for high performance with minimal allocations. ### Allocation-Free Execution Use the state-based overloads to achieve zero heap allocations in hot paths: ```csharp // Instead of capturing variables in a closure (allocates): var userId = GetUserId(); await policy.ExecuteAsync(async ct => await GetUserAsync(userId, ct)); // Pass state explicitly (zero allocations): var userId = GetUserId(); await policy.ExecuteAsync(userId, async (id, ct) => await GetUserAsync(id, ct)); ``` ### Sync vs Async Choose the appropriate execution method: ```csharp // Use sync for CPU-bound or already-completed operations var cachedValue = policy.Execute(_ => cache.Get(key)); // Use async for I/O-bound operations var apiResult = await policy.ExecuteAsync(async ct => await api.CallAsync(ct)); ``` ### Benchmark Results Foundatio consistently outperforms alternatives when retry policies are configured: | Scenario | Foundatio | Polly | Foundatio Advantage | | ------------------------- | -------------- | -------------- | ------------------------------------ | | Sync with retries | ~23 ns | ~122 ns | **5.3x faster** | | Async with retries | ~37 ns | ~141 ns | **3.8x faster** | | State-based (zero-alloc) | ~31 ns, 0 B | ~131 ns, 88 B | **4.2x faster, zero allocations** | Benchmarks run on AMD Ryzen 7 9800X3D, .NET 10.0 ## Next Steps * [Caching](./caching) - Combine with cache fallbacks * [Queues](./queues) - Resilient queue processing * [Jobs](./jobs) - Retry job execution --- --- url: /guide/serialization.md --- # Serialization All Foundatio implementations use serialization for storing and transmitting data. Understanding serialization options helps you optimize performance and choose the right format for your needs. ## ISerializer Interface Foundatio defines a simple serialization interface that all implementations use: [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Serializer/ISerializer.cs) ```csharp /// /// Defines methods for serializing and deserializing objects to and from streams. /// public interface ISerializer { /// /// Deserializes data from a stream into an object of the specified type. /// object? Deserialize(Stream data, Type objectType); /// /// Serializes an object to the specified output stream. /// Null values are valid and will be serialized (e.g., as "null" for JSON serializers /// or nil markers for binary serializers). /// void Serialize(object? value, Stream output); } /// /// Marker interface for serializers that produce human-readable text output (e.g., JSON, XML). /// Text serializers use UTF-8 encoding for string conversions in extension methods. /// public interface ITextSerializer : ISerializer { } ``` This abstraction allows you to swap serializers without changing your code. ## Extension Methods The `SerializerExtensions` class provides convenient methods for common serialization scenarios: ```csharp // Deserialize from various sources T Deserialize(this ISerializer serializer, Stream data) T Deserialize(this ISerializer serializer, byte[] data) T Deserialize(this ISerializer serializer, string data) object Deserialize(this ISerializer serializer, byte[] data, Type objectType) object Deserialize(this ISerializer serializer, string data, Type objectType) // Serialize to various formats byte[] SerializeToBytes(this ISerializer serializer, T value) string SerializeToString(this ISerializer serializer, T value) ``` **Input Validation:** * **Deserialization methods** validate inputs and throw exceptions: * `ArgumentNullException` - when serializer, stream, or byte array is null * `ArgumentException` - when byte array is empty, or string is null, empty, or whitespace * **Serialization methods** (`SerializeToBytes`, `SerializeToString`): * Throw `ArgumentNullException` if serializer is null * Null values are serialized to valid output (e.g., `"null"` for JSON, nil marker for MessagePack) **Text vs Binary Serializers:** When using string-based methods: * **Text serializers** (`ITextSerializer`): Strings are treated as UTF-8 encoded text * **Binary serializers**: Strings are treated as Base64-encoded binary data ## Default Serializer Foundatio uses `SystemTextJsonSerializer` (System.Text.Json) by default: ```csharp // Default - uses System.Text.Json var cache = new InMemoryCacheClient(); // Equivalent to: var cache = new InMemoryCacheClient(o => o.Serializer = new SystemTextJsonSerializer()); ``` **Why System.Text.Json?** * Built into .NET (no extra dependencies) * Fast and efficient * Human-readable JSON format * Good balance of performance and debuggability ## Custom JSON Options Configure System.Text.Json serialization behavior: ```csharp var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() } }; var serializer = new SystemTextJsonSerializer(jsonOptions); var cache = new InMemoryCacheClient(o => o.Serializer = serializer); var queue = new InMemoryQueue(o => o.Serializer = serializer); var messageBus = new InMemoryMessageBus(o => o.Serializer = serializer); ``` ## Global Default Serializer Set the default serializer for all new instances that don't explicitly specify one: ```csharp // Set globally (affects all new instances that don't specify a serializer) DefaultSerializer.Instance = new SystemTextJsonSerializer(myJsonOptions); // Now all new instances use your custom serializer var cache = new InMemoryCacheClient(); // Uses your custom serializer var queue = new InMemoryQueue(); // Uses your custom serializer ``` **How it works:** * When you don't specify a serializer, `SharedOptions.Serializer` falls back to `DefaultSerializer.Instance` * This allows you to configure serialization once for your entire application * Useful for setting up camelCase naming, custom converters, or other JSON options globally ## Available Serializers Foundatio provides several serializer implementations via NuGet packages: ### System.Text.Json (Default) ```bash # Included in Foundatio package (no extra install needed) dotnet add package Foundatio ``` [View source](https://github.com/FoundatioFx/Foundatio/blob/main/src/Foundatio/Serializer/SystemTextJsonSerializer.cs) ```csharp var serializer = new SystemTextJsonSerializer(jsonOptions); ``` **When to use:** * Default choice for most applications * Good performance and .NET native support * Human-readable JSON for debugging * Well-supported by .NET ecosystem ### Newtonsoft.Json (Json.NET) ```bash dotnet add package Foundatio.JsonNet ``` ```csharp var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto, ContractResolver = new CamelCasePropertyNamesContractResolver() }; var serializer = new JsonNetSerializer(settings); ``` **When to use:** * Need `$type` handling for polymorphic types * Existing codebase uses Newtonsoft.Json extensively * Require specific Newtonsoft.Json features not in System.Text.Json * Legacy compatibility ### MessagePack (Binary) ```bash dotnet add package Foundatio.MessagePack ``` ```csharp var options = MessagePackSerializerOptions.Standard .WithResolver(ContractlessStandardResolver.Instance); var serializer = new MessagePackSerializer(options); ``` **When to use:** * High-throughput scenarios where size and speed are critical * Queue messages, cache values in high-volume systems * Network bandwidth is limited * Binary format is acceptable (not human-readable) **Performance:** ~2-5x faster than JSON, 50-70% smaller payloads ## Performance Comparison | Serializer | Speed | Size | Human Readable | Type Info | Dependencies | |------------|-------|------|----------------|-----------|--------------| | System.Text.Json | Fast | Medium | ✅ | ❌ | Built-in | | MessagePack | **Very Fast** | **Small** | ❌ | Optional | MessagePack NuGet | | Newtonsoft.Json | Medium | Medium | ✅ | ✅ | Newtonsoft.Json NuGet | ## Choosing the Right Serializer ### Use System.Text.Json (Default) When: * Starting a new project * You want good balance of speed, size, and debuggability * You don't need advanced features like `$type` handling * You prefer built-in .NET support ### Use MessagePack When: * Processing high message volumes (>10k messages/sec) * Network bandwidth or storage size is constrained * Speed is more important than human-readability * You have control over both producer and consumer ```csharp // High-throughput queue example var serializer = new MessagePackSerializer(); var queue = new RedisQueue(o => { o.Serializer = serializer; o.ConnectionString = connectionString; }); ``` ### Use Newtonsoft.Json When: * You need `$type` handling for polymorphic serialization * Migrating from legacy code that uses Json.NET * Require specific Json.NET features (custom converters, complex scenarios) ```csharp var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto }; var serializer = new JsonNetSerializer(settings); var cache = new RedisCacheClient(o => o.Serializer = serializer); ``` ## Using Serializers with Foundatio All Foundatio implementations accept a serializer via their options: ```csharp var serializer = new MessagePackSerializer(); // Caching var cache = new InMemoryCacheClient(o => o.Serializer = serializer); // Queues var queue = new InMemoryQueue(o => o.Serializer = serializer); // Messaging var messageBus = new InMemoryMessageBus(o => o.Serializer = serializer); // Storage (for metadata serialization) var storage = new InMemoryFileStorage(o => o.Serializer = serializer); ``` For DI configuration and shared options across implementations, see [Dependency Injection](/guide/dependency-injection). ## Serialization Exceptions Foundatio provides `SerializerException` as a base exception type for serialization-specific error conditions. **Built-in serializers** (System.Text.Json, Newtonsoft.Json, MessagePack) throw their native exceptions (e.g., `JsonException`, `MessagePackSerializationException`). `SerializerException` is primarily used for: * **Testing**: The `FaultInjectingSerializer` in `Foundatio.TestHarness` throws `SerializerException` to simulate serialization failures in unit and integration tests * **Custom serializers**: Wrap implementation-specific exceptions when you want a unified exception type across your application ### Component-Specific Exception Types Foundatio provides dedicated exception types for each infrastructure component: | Component | Exception Type | Namespace | |-----------|---------------|-----------| | Caching | `CacheException` | `Foundatio.Caching` | | Queues | `QueueException` | `Foundatio.Queues` | | Messaging | `MessageBusException` | `Foundatio.Messaging` | | Storage | `StorageException` | `Foundatio.Storage` | | Serialization | `SerializerException` | `Foundatio.Serializer` | | Resilience | `BrokenCircuitException` | `Foundatio.Resilience` | These ensure consumers get predictable exception types regardless of the underlying implementation (Redis, Azure, AWS, etc.). ## Serialization Considerations ### Binary vs Text Serializers **Text Serializers (JSON):** * Implement `ITextSerializer` marker interface * Human-readable (can debug with text tools) * Slightly larger payloads * Compatible across different systems/languages * Extension methods use UTF-8 encoding for string conversions **Binary Serializers (MessagePack):** * Implement `ISerializer` only (not `ITextSerializer`) * Much faster and smaller * Not human-readable * Requires same serializer on both ends * Extension methods use Base64 encoding for string conversions ### Shared Message Bus Topics When using a shared message bus topic across multiple applications: ```csharp // All apps must use the SAME serializer var serializer = new SystemTextJsonSerializer(); // or MessagePackSerializer services.AddSingleton(serializer); services.AddSingleton(sp => new RedisMessageBus(o => { o.Topic = "events"; // Shared topic o.Serializer = sp.GetRequiredService(); })); ``` ::: warning If different applications use different serializers on the same topic, deserialization will fail. Coordinate serializer choice across all consumers. ::: ## Next Steps * [Caching](/guide/caching) - Cache-specific serialization patterns * [Queues](/guide/queues) - Queue serialization and message size considerations * [Messaging](/guide/messaging) - Message bus serialization and shared topics * [Resilience](/guide/resilience) - Configure resilience policies --- --- url: /guide/what-is-foundatio.md --- # What is Foundatio? Foundatio is a modular .NET library providing pluggable building blocks for distributed applications, including: * **Caching** - Fast data access with multiple backend implementations * **Queues** - FIFO message delivery for background processing * **Locks** - Distributed locking for resource coordination * **Messaging** - Pub/sub patterns for event-driven architectures * **Jobs** - Long-running process management * **File Storage** - Abstracted file operations * **Resilience** - Retry policies and circuit breakers ## Design Philosophy Foundatio was built with several key principles in mind: ### Abstract Interfaces All core functionality is exposed through clean interfaces (`ICacheClient`, `IQueue`, `ILockProvider`, `IMessageBus`, `IFileStorage`). This allows you to: * **Swap implementations** without changing application code * **Test easily** using in-memory implementations * **Scale gradually** by switching to distributed implementations when needed ### Dependency Injection First Every component is designed to work seamlessly with Microsoft.Extensions.DependencyInjection: ```csharp services.AddSingleton(sp => new InMemoryCacheClient()); services.AddSingleton(sp => new InMemoryMessageBus()); services.AddSingleton(sp => new CacheLockProvider( sp.GetRequiredService(), sp.GetRequiredService() )); ``` ### Development-Production Parity In-memory implementations for all abstractions mean: * **No external dependencies** during development * **Fast unit tests** without infrastructure setup * **Same code paths** in development and production ### Extensibility Each abstraction can be extended with custom implementations: ```csharp public class MyCustomCacheClient : ICacheClient { // Your custom implementation } ``` ## Core Abstractions ### Caching Store and retrieve data with expiration support: ```csharp ICacheClient cache = new InMemoryCacheClient(); await cache.SetAsync("user:123", user, TimeSpan.FromMinutes(30)); var cached = await cache.GetAsync("user:123"); ``` [Learn more about Caching →](./caching) ### Queues Reliable message delivery with at-least-once semantics: ```csharp IQueue queue = new InMemoryQueue(); await queue.EnqueueAsync(new WorkItem { Id = 1 }); var entry = await queue.DequeueAsync(); if (entry != null) { // Process and complete await entry.CompleteAsync(); } ``` [Learn more about Queues →](./queues) ### Locks Distributed locking for coordinating access: ```csharp ILockProvider locker = new CacheLockProvider(cache, messageBus); await using var lck = await locker.AcquireAsync("my-resource"); if (lck != null) { // Exclusive access to resource } ``` [Learn more about Locks →](./locks) ### Messaging Publish/subscribe messaging: ```csharp IMessageBus bus = new InMemoryMessageBus(); await bus.SubscribeAsync(msg => ProcessOrder(msg)); await bus.PublishAsync(new OrderCreated { OrderId = 123 }); ``` [Learn more about Messaging →](./messaging) ### File Storage Abstracted file operations: ```csharp IFileStorage storage = new FolderFileStorage("/data"); await storage.SaveFileAsync("reports/2024/report.pdf", fileStream); var file = await storage.GetFileStreamAsync("reports/2024/report.pdf", StreamMode.Read); ``` [Learn more about Storage →](./storage) ### Jobs Background job processing: ```csharp public class MyJob : JobBase { protected override Task RunInternalAsync(JobContext context) { // Do work return Task.FromResult(JobResult.Success); } } ``` [Learn more about Jobs →](./jobs) ### Resilience Retry policies with circuit breakers: ```csharp var policy = new ResiliencePolicyBuilder() .WithMaxAttempts(5) .WithExponentialDelay(TimeSpan.FromSeconds(1)) .WithCircuitBreaker() .Build(); await policy.ExecuteAsync(async ct => { await SomeUnreliableOperationAsync(ct); }); ``` [Learn more about Resilience →](./resilience) ## When to Use Foundatio ### ✅ Great For * **Microservices** with distributed caching, queuing, and messaging needs * **Background processing** with reliable job execution * **Event-driven architectures** using pub/sub patterns * **Cloud applications** needing portable abstractions * **Development teams** wanting consistent patterns across projects * **Testing scenarios** requiring isolated, fast-running tests ### ⚠️ Consider Alternatives For * **Simple applications** with no distributed requirements * **Projects already invested** in specific vendor SDKs * **Extremely high-throughput scenarios** where direct SDK access is needed ## Next Steps Ready to get started? Here's what to explore next: * [Getting Started](./getting-started) - Install and configure Foundatio * [Caching](./caching) - Deep dive into caching * [Why Choose Foundatio?](./why-foundatio) - Detailed comparison and benefits --- --- url: /guide/why-foundatio.md --- # Why Choose Foundatio? Foundatio was born from real-world experience building large-scale cloud applications. Here's why it stands out from other approaches. ## The Problem When building distributed applications, you typically face these challenges: 1. **Vendor Lock-in**: Direct use of Redis, Azure, or AWS SDKs couples your code to specific providers 2. **Testing Complexity**: External dependencies make testing slow and unreliable 3. **Development Setup**: Developers need to run Redis, Azure Storage Emulator, etc. locally 4. **Inconsistent Patterns**: Different team members use different approaches for the same problems 5. **Reinventing the Wheel**: Every project implements caching, queuing, locking from scratch ## The Foundatio Solution ### 🔌 Pluggable Abstractions Write your code against interfaces, not implementations: ```csharp // Your service doesn't care about the implementation public class OrderProcessor { private readonly ICacheClient _cache; private readonly IQueue _queue; public OrderProcessor(ICacheClient cache, IQueue queue) { _cache = cache; _queue = queue; } } ``` Change from in-memory to Redis with one line in your DI configuration: ```csharp // Development services.AddSingleton(); // Production services.AddSingleton(sp => new RedisCacheClient(o => o.ConnectionMultiplexer = redis)); ``` ### 🧪 Superior Testing Experience No mocking frameworks needed - use real implementations: ```csharp [Fact] public async Task Should_Process_Order_With_Caching() { // Arrange - use in-memory implementations var cache = new InMemoryCacheClient(); var queue = new InMemoryQueue(); var processor = new OrderProcessor(cache, queue); // Act await processor.ProcessAsync(new Order { Id = 1 }); // Assert var cached = await cache.GetAsync("order:1"); Assert.NotNull(cached); } ``` Benefits: * **Fast**: No network calls * **Isolated**: No shared state between tests * **Reliable**: No external service failures * **Complete**: Test the exact code path used in production ### 🚀 Zero-Config Development Start coding immediately without external dependencies: ```csharp // Works out of the box - no Redis, no Azure, no AWS var cache = new InMemoryCacheClient(); var queue = new InMemoryQueue(); var messageBus = new InMemoryMessageBus(); var storage = new InMemoryFileStorage(); ``` Compare to other approaches: * **Direct Redis**: Requires running Redis server * **Direct Azure**: Requires Azure subscription or emulator * **Direct AWS**: Requires AWS account or LocalStack ### 📊 Comparison with Alternatives #### vs. Direct SDK Usage | Aspect | Direct SDK | Foundatio | |--------|------------|-----------| | Testing | Mock everything | Use in-memory implementations | | Switching providers | Rewrite code | Change DI registration | | Local development | Run services | Zero dependencies | | Learning curve | Learn each SDK | Learn one API | #### vs. Building Your Own | Aspect | DIY | Foundatio | |--------|-----|-----------| | Time to implement | Weeks/months | Minutes | | Battle tested | No | Yes (years of production use) | | Edge cases handled | Maybe | Comprehensive | | Maintenance burden | On you | On community | #### vs. Other Libraries Foundatio is unique in providing: 1. **Complete abstraction set**: Caching, Queues, Locks, Messaging, Storage, Jobs, Resilience 2. **Consistent API design**: Same patterns across all abstractions 3. **In-memory implementations**: For all abstractions, not just some 4. **Active maintenance**: Regular updates and community support ## Real-World Usage ### Exceptionless [Exceptionless](https://github.com/exceptionless/Exceptionless), a large-scale error tracking application, uses Foundatio extensively: * **Caching**: User sessions, resolved geo-locations with `MaxItems` limit * **Queues**: Event processing pipeline * **Jobs**: Background processing for reports, cleanup, notifications * **Storage**: Error stack traces, attachments * **Messaging**: Real-time notifications ### Enterprise Applications Teams choose Foundatio for: * **Consistency**: Same patterns across all services * **Onboarding**: New developers learn one API * **Migration**: Easy to switch providers without code changes * **Testing**: Comprehensive test coverage without infrastructure ## Feature Highlights ### Hybrid Caching Combine local and distributed caching for maximum performance: ```csharp var hybridCache = new HybridCacheClient( distributedCacheClient: new RedisCacheClient(...), messageBus: new RedisMessageBus(...) ); ``` * Local cache for fastest access * Distributed cache for consistency * Message bus for cache invalidation ### Scoped Caching Easily namespace your cache keys: ```csharp var scopedCache = new ScopedCacheClient(cache, "tenant:123"); await scopedCache.SetAsync("user", user); // Key: "tenant:123:user" await scopedCache.RemoveByPrefixAsync(""); // Clears all tenant:123 keys ``` ### Throttling Locks Rate limit operations across all instances: ```csharp var throttledLocker = new ThrottlingLockProvider( cache, maxHits: 10, period: TimeSpan.FromMinutes(1) ); // Only allows 10 operations per minute across all instances await using var lck = await throttledLocker.AcquireAsync("api-call"); if (lck != null) { await CallExternalApiAsync(); } ``` ### Queue Behaviors Extend queue functionality with behaviors: ```csharp queue.AttachBehavior(new MetricsQueueBehavior(metrics)); queue.AttachBehavior(new RetryQueueBehavior(maxRetries: 3)); ``` ### Resilience Policies Built-in retry and circuit breaker: ```csharp var policy = new ResiliencePolicyBuilder() .WithMaxAttempts(5) .WithExponentialDelay(TimeSpan.FromSeconds(1)) .WithMaxDelay(TimeSpan.FromMinutes(1)) .WithJitter() .WithCircuitBreaker(cb => cb .WithFailureRatio(0.5) .WithBreakDuration(TimeSpan.FromMinutes(1))) .Build(); ``` ## Getting Started Ready to try Foundatio? 1. [Installation & Setup](./getting-started) - Get running in minutes 2. [Caching Guide](./caching) - Deep dive into caching 3. [Sample Application](https://github.com/FoundatioFx/Foundatio.Samples) - See complete examples The combination of consistent abstractions, excellent testability, and production-ready implementations makes Foundatio an excellent choice for modern .NET applications.