| id | 0057-in-memory-box-abstract-expiry | ||||
|---|---|---|---|---|---|
| title | Make InMemoryBox Abstract with Subclass-Specific Expiry Strategies | ||||
| status | Accepted | ||||
| author |
|
||||
| created | 2026-05-05 | ||||
| summary | Makes `InMemoryBox<T>` abstract with subclass-defined `RemoveExpiredMessages` and `Compact` hooks so `InMemoryOutbox` only evicts dispatched messages (preventing silent loss of undispatched messages) while `InMemoryInbox` retains its existing write-time TTL eviction. Adds an `EntryLimit = -1` option to disable compaction and exposes default outbox configuration via a new `InMemoryBoxConfiguration` record on `ProducersConfiguration`. | ||||
| tags |
|
Date: 2026-05-05
Accepted
Parent Requirement: specs/0027-Outbox-Limits/requirements.md
Scope: This ADR addresses the structural change to InMemoryBox<T> and its subclasses to support different expiry strategies, the configuration of the default InMemoryOutbox via ProducersConfiguration, and the error handling fix in OutboxProducerMediator.
InMemoryBox<T> is a concrete base class shared by InMemoryOutbox and InMemoryInbox. It provides a single RemoveExpiredMessages implementation that evicts entries based on WriteTime age — entries older than EntryTimeToLive are removed.
This is appropriate for the inbox (idempotency deduplication), but wrong for the outbox. The outbox stores messages that must be dispatched to a broker. Evicting an undispatched message means it is silently lost. The outbox already tracks dispatch state via OutboxEntry.TimeFlushed (DateTimeOffset.MinValue means "not yet dispatched"), but the base class expiry ignores this.
The forces at play:
- Inbox and outbox have fundamentally different eviction semantics. The inbox can safely remove any entry older than its TTL — the entry has served its idempotency purpose. The outbox must never remove undispatched messages; only dispatched messages (where
TimeFlushed != DateTimeOffset.MinValue) are safe to evict. - The default
InMemoryOutboxis created with hardcoded settings inServiceCollectionExtensions.AddProducers(lines ~296 and ~430). Users who don't provide an explicit outbox getEntryLimit = 2048with no way to change it. OutboxProducerMediator.ClearOutboxthrowsNullReferenceExceptionwhen a message has been compacted or evicted before dispatch, which is both the wrong exception type and unhelpful for diagnosis.
InMemoryBox<T> becomes an abstract class. The RemoveExpiredMessages method moves from a private concrete implementation to a protected abstract method. Each subclass must define its own eviction strategy.
The base class retains:
- The
ConcurrentDictionary<string, T> Requestsstore - The
ClearExpiredMessages()method (the timer-gated trigger that callsRemoveExpiredMessageson a background thread) - The
EnforceCapacityLimit()method (triggers compaction on a background thread whenEntryLimitis reached) - All configuration properties:
EntryTimeToLive,ExpirationScanInterval,EntryLimit,CompactionPercentage
Both RemoveExpiredMessages and Compact become protected abstract methods. This gives each subclass full control over both eviction strategies:
- Expiry: which entries to remove based on time
- Compaction: which entries to remove under capacity pressure
The base class owns all locking. The existing _cleanupRunningLockObject and Monitor.TryEnter pattern stays in the base class. The abstract methods are called inside the lock:
ClearExpiredMessages()checks the scan interval, then spawns a background thread that acquires the lock and callsRemoveExpiredMessages(now)inside it.EnforceCapacityLimit()checksEntryLimit, then spawns a background thread that acquires the lock and callsCompact(entriesToRemove)inside it.
Subclass overrides of RemoveExpiredMessages and Compact are guaranteed to be called under the lock and must not block or attempt to re-acquire it. They are pure eviction logic — no threading or synchronisation concerns.
// Base class pattern (pseudocode):
private void RunRemoveExpiredMessages(DateTimeOffset now)
{
if (Monitor.TryEnter(_cleanupRunningLockObject))
{
try { RemoveExpiredMessages(now); }
finally { Monitor.Exit(_cleanupRunningLockObject); }
}
}
private void RunCompact(int entriesToRemove)
{
if (Monitor.TryEnter(_cleanupRunningLockObject))
{
try
{
_lastCompactionAttemptAt = _timeProvider.GetUtcNow();
Compact(entriesToRemove);
}
finally { Monitor.Exit(_cleanupRunningLockObject); }
}
}
protected abstract void RemoveExpiredMessages(DateTimeOffset now);
protected abstract void Compact(int entriesToRemove);EnforceCapacityLimit() gains a _lastCompactionAttemptAt timestamp with a cooldown, mirroring the _lastScanAt pattern used by ClearExpiredMessages(). When the outbox cannot compact (because all entries are undispatched), this prevents spawning a background thread on every operation. The cooldown interval reuses ExpirationScanInterval — if compaction found nothing to remove, wait before trying again.
protected void EnforceCapacityLimit()
{
if (EntryLimit == -1) return;
var now = _timeProvider.GetUtcNow();
if (EntryCount >= EntryLimit)
{
if ((now - _lastCompactionAttemptAt) < ExpirationScanInterval) return;
int newSize = (int)(EntryLimit * CompactionPercentage);
int entriesToRemove = EntryCount - newSize;
Task.Factory.StartNew(
state => RunCompact((int)state!), entriesToRemove,
CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
}
}┌──────────────────────────────────────────┐
│ InMemoryBox<T> (abstract) │
│ │
│ Knowing: │
│ - Requests dictionary │
│ - EntryLimit, EntryTimeToLive │
│ - ExpirationScanInterval │
│ - CompactionPercentage │
│ - _cleanupRunningLockObject (private) │
│ - _lastScanAt, _lastCompactionAt │
│ │
│ Doing: │
│ - ClearExpiredMessages() (trigger) │
│ - EnforceCapacityLimit() (trigger) │
│ - RunRemoveExpiredMessages() (lock) │
│ - RunCompact() (lock) │
│ │
│ Deciding (delegated to subclasses): │
│ # RemoveExpiredMessages(now) │
│ # Compact(entriesToRemove) │
│ │
│ Support for EntryLimit = -1: │
│ - EnforceCapacityLimit() skips │
│ compaction when EntryLimit is -1 │
└──────────┬───────────────┬───────────────┘
│ │
┌──────┴───────┐ ┌─────┴──────┐
│InMemoryOutbox │ │InMemoryInbox│
│ │ │ │
│Expiry: │ │Expiry: │
│ Only flushed │ │ Any entry │
│ entries │ │ older than │
│ │ │ TTL │
│Compaction: │ │ │
│ Only flushed │ │Compaction: │
│ entries; │ │ Oldest by │
│ oldest by │ │ WriteTime │
│ TimeFlushed │ │ │
└──────────────┘ └────────────┘
RemoveExpiredMessages override removes entries where:
TimeFlushed != DateTimeOffset.MinValue(the message has been dispatched), and(now - TimeFlushed) >= EntryTimeToLive(enough time has passed since dispatch)
Note: EntryTimeToLive changes meaning for the outbox — it measures time since dispatch (TimeFlushed), not time since write (WriteTime). This is intentional: a message should remain available for a reasonable period after dispatch for diagnostics and replay. The default 5-minute TTL applies from dispatch time.
Compact override only compacts dispatched messages. When the entry limit is reached:
- Filter to entries where
TimeFlushed != DateTimeOffset.MinValue(dispatched only) - Order by
TimeFlushed(oldest dispatched first) - Remove entries until the target size is reached
The Compact(int entriesToRemove) signature passes the number of entries the base class would like removed. The outbox implementation removes up to that many dispatched entries — it is best-effort. If there are not enough dispatched messages, the outbox compacts as many as it can. Undispatched messages are never removed by either expiry or compaction — they are protected until they are dispatched and aged out. This means the outbox can temporarily exceed EntryLimit if all entries are undispatched; this is the correct trade-off, as losing an undispatched message is a data loss bug.
When the outbox cannot compact enough entries, the compaction cooldown (see section 1) prevents EnforceCapacityLimit() from spawning a background thread on every subsequent operation. The cooldown reuses ExpirationScanInterval — if compaction found nothing to remove, it waits before trying again.
This fully addresses FR-4: "Undispatched messages must never be removed by expiry, regardless of age."
RemoveExpiredMessages override uses the current algorithm unchanged: remove entries where (now - WriteTime) >= EntryTimeToLive. This preserves existing behaviour.
Compact override uses the current algorithm unchanged: order by WriteTime (oldest first), remove entries until the target size is reached. The inbox has no concept of dispatch state, so all entries are eligible for compaction.
EnforceCapacityLimit() in the base class gains a guard: if EntryLimit == -1, return immediately without compacting. Expiry-based cleanup still runs (controlled by ExpirationScanInterval and delegated to the subclass override).
Brighter always has an outbox — even when users call Post followed by ClearOutbox/Dispatch (rather than using a persistent outbox), the framework creates a default InMemoryOutbox. Users in high-throughput environments (particularly with RMQ or Kafka, where asynchronous confirmations mean messages linger in the outbox) are stumbling into the 2048-entry default limit without realising it exists. The configuration must be discoverable and self-documenting so that users fall into the pit of success.
Collate the related box properties into a single record type, making it clear they belong together and what their defaults mean:
/// <summary>
/// Configuration for the default in-memory box used when no explicit outbox is provided.
/// Brighter always requires an outbox — even when using Post with ClearOutbox/Dispatch
/// rather than a persistent outbox. In those scenarios, an InMemoryOutbox is created
/// automatically with these settings. For high-throughput environments (especially with
/// brokers like RMQ or Kafka where asynchronous confirmations keep messages in the outbox
/// longer), you may need to increase the EntryLimit or set it to -1 to disable compaction.
/// </summary>
/// <param name="EntryLimit">
/// Maximum number of entries before compaction runs. Default is 2048.
/// Set to -1 to disable compaction entirely (expiry still runs).
/// </param>
/// <param name="EntryTimeToLive">
/// How long after dispatch (for outbox) or after write (for inbox) before an entry
/// can be evicted by the expiry scan. Default is 5 minutes.
/// </param>
/// <param name="ExpirationScanInterval">
/// Minimum interval between expiry scans. Also used as the cooldown between
/// compaction attempts. Default is 10 minutes.
/// </param>
/// <param name="CompactionPercentage">
/// Target size as a fraction of EntryLimit after compaction.
/// 0.0 = remove as many eligible entries as possible.
/// 0.5 = compact down to 50% of the limit (default).
/// </param>
public record InMemoryBoxConfiguration(
int EntryLimit = 2048,
TimeSpan? EntryTimeToLive = null,
TimeSpan? ExpirationScanInterval = null,
double CompactionPercentage = 0.5
)
{
/// <summary>How long before an entry can be evicted. Defaults to 5 minutes.</summary>
public TimeSpan EntryTimeToLive { get; init; } = EntryTimeToLive ?? TimeSpan.FromMinutes(5);
/// <summary>Minimum interval between scans. Defaults to 10 minutes.</summary>
public TimeSpan ExpirationScanInterval { get; init; } = ExpirationScanInterval ?? TimeSpan.FromMinutes(10);
}Using a record gives us immutability after construction, with expression support for overriding individual values, and a compact declaration.
Add a single property to ProducersConfiguration (not the IAmProducersConfiguration interface — this is only relevant when no explicit outbox is provided):
/// <summary>
/// Configuration for the default InMemoryOutbox that Brighter creates when no explicit
/// outbox is provided. Brighter always uses an outbox — even for Post with
/// ClearOutbox/Dispatch workflows — so this controls the in-memory box that backs
/// those operations.
///
/// Defaults: EntryLimit = 2048, EntryTimeToLive = 5 min,
/// ExpirationScanInterval = 10 min, CompactionPercentage = 0.5.
///
/// For high-throughput scenarios (RMQ/Kafka with async confirmations), increase
/// EntryLimit or set it to -1 to disable compaction.
/// </summary>
public InMemoryBoxConfiguration DefaultBoxConfiguration { get; set; } = new();Users configure it naturally:
// Increase the limit
services.AddBrighter()
.AddProducers(config =>
{
config.DefaultBoxConfiguration = new InMemoryBoxConfiguration(EntryLimit: 8192);
config.ProducerRegistry = ...;
});
// Disable compaction entirely
services.AddBrighter()
.AddProducers(config =>
{
config.DefaultBoxConfiguration = new InMemoryBoxConfiguration(EntryLimit: -1);
config.ProducerRegistry = ...;
});There are two AddProducers overloads that both create a default outbox:
First overload (Action<ProducersConfiguration>, line ~247): constructs a local ProducersConfiguration directly, so the concrete type and DefaultBoxConfiguration are available.
Second overload (Func<IServiceProvider, ProducersConfiguration>, line ~376): the configure(sp) call returns a ProducersConfiguration, but it is registered as IAmProducersConfiguration in DI. The outbox factory lambda at line ~423 resolves IAmProducersConfiguration, losing access to the concrete type. To fix this, the outbox creation in the second overload casts back to ProducersConfiguration (which is safe — the factory at line ~389 always returns one). If the cast fails (defensive), fall back to the built-in defaults.
Both overloads use the same CreateDefaultOutbox helper:
var outbox = busConfiguration.Outbox ?? CreateDefaultOutbox(busConfiguration);
// where:
static InMemoryOutbox CreateDefaultOutbox(IAmProducersConfiguration config)
{
var outbox = new InMemoryOutbox(TimeProvider.System);
if (config is ProducersConfiguration { DefaultBoxConfiguration: var boxConfig })
{
outbox.EntryLimit = boxConfig.EntryLimit;
outbox.EntryTimeToLive = boxConfig.EntryTimeToLive;
outbox.ExpirationScanInterval = boxConfig.ExpirationScanInterval;
outbox.CompactionPercentage = boxConfig.CompactionPercentage;
}
return outbox;
}When the cast succeeds (the normal case), the user's configuration is applied. When it fails (a custom IAmProducersConfiguration implementation), the InMemoryOutbox uses its built-in defaults — the same behaviour as today.
Replace the throw new NullReferenceException(...) in both ClearOutbox and ClearOutboxAsync with error logging. When messages are missing from the outbox:
- Log an error with the missing message IDs.
- Continue dispatching the messages that were found.
This is the correct behaviour because the messages may have been dispatched by another mechanism (e.g. the sweeper), or compacted under memory pressure. Throwing halts dispatch of all remaining messages in the batch, making a partial failure into a total failure.
| Decision | Requirement |
|---|---|
1. Make InMemoryBox<T> abstract |
FR-3 |
| 2. Outbox dispatched-only eviction and compaction | FR-4 |
| 3. Inbox existing algorithms | FR-5 |
4. EntryLimit = -1 |
FR-2 |
5. Configurable via ProducersConfiguration |
FR-1 |
6. Fix NullReferenceException |
FR-6 |
- Undispatched outbox messages are safe from both expiry and compaction. The outbox only evicts messages that have been confirmed as dispatched, fully eliminating the root cause of #4115.
- Each subclass owns its eviction strategy. Making
InMemoryBox<T>abstract forces future subclasses to make an explicit decision about eviction — there is no inherited default to silently get wrong. - Users can tune the default outbox. The new
ProducersConfigurationproperties mean users no longer need to construct anInMemoryOutboxthemselves to change limits. - Missing messages are handled gracefully. Logging instead of throwing means partial outbox misses don't cascade into total dispatch failure.
- Breaking change for any external subclass of
InMemoryBox<T>. Any third-party code that extendsInMemoryBox<T>will need to implement bothRemoveExpiredMessagesandCompact. This is unlikely to affect anyone in practice, as the class is not designed for external extension, but it is a binary-breaking change. InMemoryInbox.Addnow callsEnforceCapacityLimit(). Previously the inbox only performed TTL-based eviction; compaction was never triggered. After this change, an inbox that exceedsEntryLimit(default 2048) will compact on the nextAdd, removing the oldest entries byWriteTime. Users storing more than 2048 idempotency tokens should either increaseEntryLimitor set it to-1to disable compaction.- More configuration surface.
DefaultBoxConfigurationonProducersConfigurationadds API surface. Mitigated by sensible defaults matching current behaviour.
- Risk: With
EntryLimit = -1and long-running processes, the outbox could grow unbounded if messages are never dispatched.- Mitigation: Expiry still runs and removes dispatched messages. The
-1option is documented as appropriate only when users have a reliable dispatch mechanism. The outbox is primarily for dev/test; production users should use a persistent outbox.
- Mitigation: Expiry still runs and removes dispatched messages. The
- Risk: When the outbox is at capacity with mostly undispatched messages, compaction cannot free space.
- Mitigation: The compaction cooldown (reusing
ExpirationScanInterval) prevents repeated no-op compaction attempts. Users in high-throughput scenarios should setEntryLimit = -1to disable compaction entirely, relying on expiry of dispatched messages for cleanup.
- Mitigation: The compaction cooldown (reusing
- Risk: Changing
InMemoryBox<T>from concrete to abstract could break deserialization or reflection-based usage.- Mitigation:
InMemoryBox<T>is never directly instantiated in the codebase — only throughInMemoryOutboxandInMemoryInbox. A search fornew InMemoryBoxconfirms no direct construction.
- Mitigation:
Keep InMemoryBox<T> concrete with a virtual method and default implementation. This would be non-breaking for external subclasses.
Rejected because: A virtual method with a default implementation means future subclasses silently inherit inbox-style eviction, which is wrong for outbox-like use cases. Making it abstract forces an explicit choice, following the principle of "reveal intention; be explicit to support future readers."
Pass a Func<ConcurrentDictionary<string, T>, DateTimeOffset, TimeSpan, IEnumerable<string>> eviction strategy to the constructor.
Rejected because: This adds a layer of indirection without benefit. The two subclasses have clear, stable, different strategies. A delegate would obscure the intent and make the code harder to understand. "If the implementation is hard to explain, it's a bad idea."
Let users construct and configure InMemoryOutbox themselves.
Rejected because: This is the current workaround and it fails in practice — users don't realise the default outbox has a 2048 limit until messages are silently lost. The pit of success should be the default configuration.
- Requirements: specs/0027-Outbox-Limits/requirements.md
- Issue: #4115
- Design Inspiration: MS System.Extensions.Caching.Memory.MemoryCache (cited in InMemoryOutbox source)