# Leren LRU's - Wanneer overschrijding van de capaciteit maakt uw systeem beter

<!--category-- ASP.NET, Architecture, CQRS, Bot Detection, Caching, Systems Design -->
<datetime class="hidden">2025-12-09T12:00</datetime>

De meeste systemen degraderen wanneer overbelast. Geheugen vult zich, vragen vertragen, gebruikers klagen, servers crashen.

Een paar ongebruikelijke krijgen *beter*.

Dit artikel laat zien hoe een **LRU-gebaseerd gedragsgeheugen** wordt zelf-optimaliserend wanneer het raakt capaciteit - en hoe dit patroon geeft het leersysteem in mijn [botdetectiemotor](/blog/botdetection-introduction). Dit is ook de kleinste versie van [DiSE-architectuur](/blog/dise-architecture-overview) - gecontroleerde evolutie door middel van hulpbrondruk.

Als je mijn artikel hebt gelezen [CQRS en Event Sourcing](/blog/moderncqrsandeventsourcing)Maar dit is CQRS tot op het bot gestript - geen event store, geen projecties, geen Marten. Gewoon een geheugen cache, een achtergrond werknemer, en SQLite.

[TOC]

## Het basisidee - Gedragsgeheugen voor een begroting

Voordat we erin duiken, laat me een term definiëren die ik gedurende de hele tijd zal gebruiken: **ondertekening**. Een handtekening is een stabiele sleutel die een gedragspatroon vertegenwoordigt - een hash van IP + User-Agent, een vingerafdruk van header combinaties, een detector classificatie van "dit verzoek ziet eruit als X." De cache slaat deze handtekeningen samen met geleerde gewichten die evolueren in de tijd.

Wat als je een systeem kon bouwen dat:

- Reageert in minder dan 1ms
- Nooit blokken op database schrijft
- Zelfoptimaliseert onder druk
- Vergeet wat er niet toe doet.
- Herinnert zich wat doet

Dat is precies wat `IMemoryCache` met een glijdend verloop geeft je - als je begrijpt wat je aan het bouwen bent.

### Wat een LRU Cache Eigenlijk doet

LRU (Last Recent Used) caches uitzetten items die niet zijn benaderd onlangs. Wanneer de cache vult, de koudste items worden weggegooid om plaats te maken voor hete degenen.

De meeste ontwikkelaars zien dit als een beperking. *"Oh nee, mijn cache is vol, gegevens worden verloren!"*

Maar voor gedragssystemen is dit een kenmerk.

```csharp
// From WeightStore.cs - the bounded memory window
_cache = new MemoryCache(new MemoryCacheOptions
{
    SizeLimit = _cacheSize,           // e.g., 1000 entries
    CompactionPercentage = 0.25       // Remove 25% when limit reached
});
```

Dat `SizeLimit` Het is niet alleen een geheugenbeperking. **selectiedruk**Het bepaalt hoeveel het systeem "herinnert" en dwingt het zich te concentreren op wat belangrijk is.

### Schuifduur - de klok die vergeet

In combinatie met glijdend verlopen, krijg je automatisch vergeten:

```csharp
// From WeightStore.cs:254-259
private MemoryCacheEntryOptions GetCacheEntryOptions()
{
    return new MemoryCacheEntryOptions()
        .SetSlidingExpiration(_slidingExpiration)  // 30 minutes
        .SetSize(1);  // Each entry counts as 1 toward size limit
}
```

Als een handtekening niet binnen 30 minuten toegankelijk is, wordt het uitgezet, niet omdat het verkeerd is, omdat het niet langer relevant is.

Dit creëert **natuurlijk vergeten**:

- IP's worden opnieuw toegewezen
- Bots draaien handtekeningen
- Verkeerspatronen verschuiven
- De aanvallen van gisteren zijn niet van vandaag.

Statische bloklijsten gaan oud. Schuiftijd houdt het geheugen fris.

## Het IMemoryCache patroon - Kleine CQRS zonder CQRS te zeggen

Hier is het patroon dat het laat werken. `SqliteWeightStore` klasse:

```csharp
/// <summary>
///     SQLite implementation of the weight store with sliding expiration memory cache.
///     Uses a CQRS-style pattern with write-behind:
///     - Reads: Hit memory cache first (fast path), fall back to SQLite on miss
///     - Writes: Update cache immediately, queue SQLite writes for background flush
///     Sliding expiration provides automatic LRU-like eviction behavior.
/// </summary>

public class SqliteWeightStore : IWeightStore, IAsyncDisposable
{
    // Memory cache with sliding expiration - auto-evicts least recently used entries
    private readonly MemoryCache _cache;

    // Write-behind queue for batched SQLite persistence
    private readonly ConcurrentDictionary<string, PendingWrite> _pendingWrites = new();
    private readonly Timer _flushTimer;
    private readonly TimeSpan _flushInterval = TimeSpan.FromMilliseconds(500);
```

Dit is [informele CQRS](/blog/moderncqrsandeventsourcing#part-2-the-half-assed-approach-cache-invalidation) - maar zonder de vervelende cache ongeldigheid. In plaats van het ongeldig maken van cache-items na het schrijven, **de cache IS het schrijfmodel**. SQLite is slechts het duurzame grootboek.

```mermaid
flowchart LR
    subgraph Cache["In-Memory Behaviour Store"]
        A[Hot Signatures] --- B[Sliding Expiry]
    end
    subgraph DB["SQLite Ledger"]
        C[(Durable Write-Behind)]
    end

    A --Periodic Flush--> C
    B --Eviction--> D[Forgotten]

    style Cache fill:none,stroke:#10b981,stroke-width:2px
    style DB fill:none,stroke:#6366f1,stroke-width:2px
```

Belangrijkste inzicht: **lezen en schrijven gaan naar het geheugen**De database is uiteindelijk consistent - en dat is prima.

## Het leespad - Cache eerst, altijd

Wanneer een detector een geleerd gewicht nodig heeft, raakt hij de cache:

```csharp
// From WeightStore.cs:421-472
public async Task<double> GetWeightAsync(
    string signatureType,
    string signature,
    CancellationToken ct = default)
{
    var key = CacheKey(signatureType, signature);

    // Check cache first (fast path - no DB access)
    if (_cache.TryGetValue(key, out LearnedWeight? cached) && cached != null)
    {
        _metrics?.RecordCacheHit(signatureType);
        return cached.Weight * cached.Confidence;
    }

    _metrics?.RecordCacheMiss(signatureType);

    // Cache miss - load from DB
    await EnsureInitializedAsync(ct);

    await using var conn = new SqliteConnection(_connectionString);
    await conn.OpenAsync(ct);

    var sql = $@"
        SELECT weight, confidence, observation_count, first_seen, last_seen
        FROM {TableName}
        WHERE signature_type = @type AND signature = @sig
    ";

    // ... execute query ...

    if (await reader.ReadAsync(ct))
    {
        // Cache the result for future reads
        var learnedWeight = new LearnedWeight { /* ... */ };
        _cache.Set(key, learnedWeight, GetCacheEntryOptions());

        return weight * confidence;
    }

    return 0.0;  // No learned weight exists
}
```

Warme paden **Nooit in de database geraakt**. De cache is de bron van de waarheid voor lezen. SQLite is gewoon back-up opslag.

## The Write Path - Cache Direct, Persist Later

Wanneer het systeem iets nieuws leert, wordt de cache onmiddellijk bijgewerkt en wordt de database in de wachtrij geschreven:

```csharp
// From WeightStore.cs:551-582
public Task UpdateWeightAsync(
    string signatureType,
    string signature,
    double weight,
    double confidence,
    int observationCount,
    CancellationToken ct = default)
{
    var key = CacheKey(signatureType, signature);

    // Update cache immediately (source of truth for reads)
    var learnedWeight = new LearnedWeight
    {
        SignatureType = signatureType,
        Signature = signature,
        Weight = weight,
        Confidence = confidence,
        ObservationCount = observationCount,
        FirstSeen = DateTimeOffset.UtcNow,
        LastSeen = DateTimeOffset.UtcNow
    };
    _cache.Set(key, learnedWeight, GetCacheEntryOptions());

    // Queue for async SQLite persistence (write-behind)
    QueueWrite(signatureType, signature, weight, confidence, observationCount);

    return Task.CompletedTask;
}
```

Opmerking: `UpdateWeightAsync` geeft `Task.CompletedTask` - het is in wezen synchroon. Het schrijven is in de wachtrij, niet uitgevoerd. Dit betekent:

- Submilliseconde schrijflatentie
- Niet blokkeren op I/O
- Schrijven zijn gecoalesced (laatste schrijf overwinningen)

## De achtergrond Flusher - 500ms van saaie magie

Elke 500m, in afwachting van schrijven worden doorgespoeld naar SQLite in een enkele batch:

```csharp
// From WeightStore.cs:274-357
public async Task FlushPendingWritesAsync(CancellationToken ct = default)
{
    if (_pendingWrites.IsEmpty) return;

    // Only one flush at a time
    if (!await _flushLock.WaitAsync(0, ct)) return;

    try
    {
        await EnsureInitializedAsync(ct);

        // Snapshot and clear pending writes atomically
        var writes = new List<PendingWrite>();
        foreach (var key in _pendingWrites.Keys.ToList())
        {
            if (_pendingWrites.TryRemove(key, out var write))
            {
                writes.Add(write);
            }
        }

        if (writes.Count == 0) return;

        await using var conn = new SqliteConnection(_connectionString);
        await conn.OpenAsync(ct);
        await using var transaction = await conn.BeginTransactionAsync(ct);

        try
        {
            var sql = $@"
                INSERT INTO {TableName}
                    (signature_type, signature, weight, confidence,
                     observation_count, first_seen, last_seen)
                VALUES (@type, @sig, @weight, @conf, @count, @now, @now)
                ON CONFLICT(signature_type, signature) DO UPDATE SET
                    weight = @weight,
                    confidence = @conf,
                    observation_count = @count,
                    last_seen = @now
            ";

            foreach (var write in writes)
            {
                await using var cmd = new SqliteCommand(sql, conn, transaction);
                // ... add parameters and execute ...
            }

            await transaction.CommitAsync(ct);
            _logger.LogDebug("Flushed {Count} pending writes in {Duration:F1}ms",
                writes.Count, sw.ElapsedMilliseconds);
        }
        catch
        {
            await transaction.RollbackAsync(ct);
            throw;
        }
    }
    finally
    {
        _flushLock.Release();
    }
}
```

Dit is **event-sourcing-light**Je krijgt:

- Batched writes (efficient I/O)
- Transactiesamenhang
- Gecoalesceerde updates (als dezelfde handtekening 10 keer wordt bijgewerkt in 500ms, wordt alleen de uiteindelijke waarde geschreven)
- SQLite is perfect blij met dit toegangspatroon

## EMA-updates - Leren met exponentieel bewegende gemiddelden

Wanneer een nieuwe waarneming aankomt, gebruikt het systeem exponentieel bewegende gemiddelden om gewichten bij te werken:

```csharp
// From WeightStore.cs:584-635
public Task RecordObservationAsync(
    string signatureType,
    string signature,
    bool wasBot,
    double detectionConfidence,
    CancellationToken ct = default)
{
    var key = CacheKey(signatureType, signature);

    // Calculate new weight using EMA in memory
    var alpha = 0.1;  // Learning rate
    var weightDelta = wasBot ? detectionConfidence : -detectionConfidence;

    double newWeight;
    double newConfidence;
    int newObservationCount;

    if (_cache.TryGetValue(key, out LearnedWeight? existing) && existing != null)
    {
        // Apply EMA: new_weight = old_weight * (1-α) + delta * α
        newWeight = existing.Weight * (1 - alpha) + weightDelta * alpha;
        newConfidence = Math.Min(1.0, existing.Confidence + detectionConfidence * 0.01);
        newObservationCount = existing.ObservationCount + 1;
    }
    else
    {
        // First observation
        newWeight = weightDelta;
        newConfidence = detectionConfidence;
        newObservationCount = 1;
    }

    // Update cache immediately
    var learnedWeight = new LearnedWeight
    {
        SignatureType = signatureType,
        Signature = signature,
        Weight = newWeight,
        Confidence = newConfidence,
        ObservationCount = newObservationCount,
        FirstSeen = existing?.FirstSeen ?? DateTimeOffset.UtcNow,
        LastSeen = DateTimeOffset.UtcNow
    };
    _cache.Set(key, learnedWeight, GetCacheEntryOptions());

    // Queue for persistence
    QueueWrite(signatureType, signature, newWeight, newConfidence, newObservationCount);

    return Task.CompletedTask;
}
```

De EMA formule maakt het leren soepel: `new_weight = old_weight × (1 - α) + new_value × α`

Met α = 0,1:

- Nieuw bewijsmateriaal draagt 10% bij
- Historisch bewijs draagt 90% bij
- Dit voorkomt wilde schommels van enkele waarnemingen

## Waarom Overflow maakt het systeem *Beter*

Hier is het belangrijkste inzicht dat de meeste mensen missen.

Als de cache vol is:

- Laagfrequente handtekeningen vallen uit
- Alleen hete (vaak toegankelijke) handtekeningen blijven in het geheugen
- De database loopt met één flush cyclus achter - en dat is prima
- Het systeem **focussen** onder druk

Denk er eens over na: als 50.000 unieke handtekeningen je botdetector raken, maar je hebt maar 10.000 geheugen, welke handtekeningen er toe doen?

**De heetste 10.000.** - die gewoonlijk 99% van het werkelijke verkeer vertegenwoordigen.

In de productie, zie ik ongeveer 40.000 eenmalige handtekeningen per dag (schrapers proberen eenmaal, willekeurige sondes, legitieme gebruikers die nooit terugkeren) en misschien 5

```mermaid
flowchart TB
    subgraph Input["50,000 Unique Signatures"]
        Hot[Hot Signatures\n~10,000]
        Cold[Cold Signatures\n~40,000]
    end

    subgraph Cache["Bounded Cache (10,000)"]
        Kept[Kept in Memory]
    end

    subgraph Evicted["Evicted"]
        Lost[Forgotten\nNoise Traffic]
    end

    Hot --> Kept
    Cold --> Lost

    style Hot fill:none,stroke:#10b981,stroke-width:2px
    style Cold fill:none,stroke:#94a3b8,stroke-width:2px
    style Kept fill:none,stroke:#10b981,stroke-width:2px
    style Lost fill:none,stroke:#ef4444,stroke-width:2px
```

Overloop **slijpt** Het gedragsgeheugen, het systeem zelfoptimaliseert.

### Wanneer overloop niet helpt

Dit is geen magie. Er zijn rand gevallen waar LRU druk werkt tegen je:

- **Cache te klein**: Als je cache slechts 100 items bevat, maar je hebt 1.000 echt belangrijke handtekeningen, zul je voortdurend karnen en verliezen nuttige patronen voordat ze genoeg bewijs verzamelen. Grootte van je cache om comfortabel uw "hot set" vast te houden.

- **Uniform verkeer**: Als je draait op een klein intern systeem waar bijna alles is "hot" (enkele unieke handtekeningen, alle terugkerende), overflow geeft u minder voordeel. De selectie druk heeft niets om tegen te kiezen.

- **Koudstartprobleem**: Een vers ingezet systeem heeft geen geleerd gewichten. Alles is even koud. De eerste uren zullen hogere vals positieve tarieven hebben totdat de hete handtekeningen zich vestigen.

Het patroon werkt het beste als je **hoge kardinaliteit met machtsverdeling** - veel unieke handtekeningen, maar een kleine subset die het verkeer domineert. Dat is precies hoe bot detectie verkeer eruit ziet.

## Cache en database in synchronisatie houden - Tag-based ongeldigheid

De cache is de bron van de waarheid voor lezen, maar de database is het duurzame grootboek. Wat gebeurt er als ze drijven?

### Synchronisatie wissen

Wanneer database gewichten verval, de cache moet volgen. `DecayOldWeightsAsync` methode behandelt beide:

```csharp
// From WeightStore.cs:725-766
public async Task DecayOldWeightsAsync(TimeSpan maxAge, double decayFactor, CancellationToken ct = default)
{
    await EnsureInitializedAsync(ct);

    await using var conn = new SqliteConnection(_connectionString);
    await conn.OpenAsync(ct);

    var cutoff = DateTimeOffset.UtcNow.Subtract(maxAge).ToString("O");

    // Decay old weights in the database
    var sql = $@"
        UPDATE {TableName}
        SET weight = weight * @decay,
            confidence = confidence * @decay
        WHERE last_seen < @cutoff
    ";

    await using var cmd = new SqliteCommand(sql, conn);
    cmd.Parameters.AddWithValue("@decay", decayFactor);
    cmd.Parameters.AddWithValue("@cutoff", cutoff);

    var updated = await cmd.ExecuteNonQueryAsync(ct);

    // Delete weights that have decayed below threshold
    var deleteSql = $@"
        DELETE FROM {TableName}
        WHERE confidence < 0.01 OR (ABS(weight) < 0.01 AND observation_count < 5)
    ";

    await using var deleteCmd = new SqliteCommand(deleteSql, conn);
    var deleted = await deleteCmd.ExecuteNonQueryAsync(ct);

    if (updated > 0 || deleted > 0)
    {
        _logger.LogInformation(
            "Weight decay: {Updated} decayed, {Deleted} deleted",
            updated, deleted);

        // Compact cache to remove stale entries
        _cache.Compact(0.25);
    }
}
```

Na het verval van database records, bellen we `_cache.Compact(0.25)` - dit dwingt de `MemoryCache` om 25% van de items te verwijderen, waarbij prioriteit wordt gegeven aan de minst recent gebruikte. De volgende lezing zal nieuwe waarden uit de database herladen.

### Tag-based uitzetting

Soms moet je een hele categorie van gecachede items ongeldig maken - bijvoorbeeld wanneer je een detector omschakelt of wanneer externe gegevens veranderen:

```csharp
// From WeightStore.cs:768-777
/// <summary>
///     Evicts all cached entries for a specific signature type (tag-based eviction).
/// </summary>

public void EvictByTag(string signatureType)
{
    // MemoryCache doesn't natively support tag-based eviction, but we can compact
    // For now, just compact - sliding expiration will handle stale entries
    _cache.Compact(0.1);
    _logger.LogDebug("Compacted cache for signature type: {SignatureType}", signatureType);
}
```

.NET's `MemoryCache` heeft geen native tag-based uitzetting zoals Redis, maar verdichting bereikt hetzelfde effect: force out oude ingangen, laten we lezen herbevolken uit de database.

### Synchronisatiestrategie

Het belangrijkste inzicht is dat **perfecte synchronisatie is niet nodig**. Het systeem tolereert drift omdat:

1. **Cache mist herladen van DB** - als een item wordt verwijderd, haalt de volgende gelezen nieuwe gegevens op
2. **Sliding expiration handvatten matheid** - Inzendingen worden niet binnen 30 minuten automatisch verwijderd
3. **Verdichting dwingt tot vernieuwing** - periodieke verdichting duwt oude items uit
4. **Write-behind coalesces updates** - meerdere snelle updates worden één DB schrijven

Dit is uiteindelijk consistentie goed gedaan. De cache blijft "dicht genoeg" bij de database zonder complexe ongeldigheid logica.

```mermaid
flowchart TB
    subgraph Sync["Cache-Database Synchronisation"]
        D[Database Decay] --> C[Cache Compact]
        E[Tag Eviction] --> C
        S[Sliding Expiration] --> M[Cache Miss]
        M --> R[Reload from DB]
    end

    style Sync fill:none,stroke:#6366f1,stroke-width:2px
```

Geen cache ongeldigheid hel. Geen complexe pub / sub. Gewoon verdichting en natuurlijke vervaldatum.

## Dieper: Het Reputatiesysteem

> **Opmerking:** Als je gewoon de LRU + schrijfachtergrond patroon wilde, kunt u hier stoppen. De rest van dit artikel laat zien hoe ik dezelfde ideeën toe te passen op volledige patroon reputatie - staat machines, hysteresis, en tijd verval. Het is de "extra mijl" voor die het bouwen van adaptieve systemen.

### Hysterese en decay

Voor patroonreputatie (volgend of een handtekening bot of mens is in de loop van de tijd) gelden dezelfde principes, maar met extra verfijning:

```csharp
// From PatternReputation.cs:42-108
public record PatternReputation
{
    public required string PatternId { get; init; }
    public required string PatternType { get; init; }
    public required string Pattern { get; init; }

    /// <summary>Current bot probability [0,1]. 0 = human, 1 = bot, 0.5 = neutral</summary>

    public double BotScore { get; init; } = 0.5;

    /// <summary>Effective sample count - decays over time, increases with observations</summary>

    public double Support { get; init; } = 0;

    /// <summary>Current reputation state - determines fast-path behavior</summary>

    public ReputationState State { get; init; } = ReputationState.Neutral;

    // Computed properties
    public double Confidence => Math.Min(1.0, Support / 100.0);

    public bool CanTriggerFastAbort =>
        State is ReputationState.ConfirmedBad or ReputationState.ManuallyBlocked;

    public bool CanTriggerFastAllow =>
        State is ReputationState.ConfirmedGood or ReputationState.ManuallyAllowed;
}
```

### Overgangen van de staat met hysterese

Patronen draaien niet rechtstreeks van Neutraal naar BevestigdBad. Er is hysteresis om flappen te voorkomen:

```csharp
// From PatternReputation.cs:367-421 - simplified
public PatternReputation EvaluateStateChange(PatternReputation reputation)
{
    if (reputation.IsManual)
        return reputation;

    var newState = reputation.State;
    var score = reputation.BotScore;
    var support = reputation.Support;

    switch (reputation.State)
    {
        case ReputationState.Neutral:
            // Can promote to Suspect or ConfirmedGood
            if (score >= 0.6 && support >= 10)
                newState = ReputationState.Suspect;
            else if (score <= 0.1 && support >= 100)
                newState = ReputationState.ConfirmedGood;
            break;

        case ReputationState.Suspect:
            // Can promote to ConfirmedBad or demote to Neutral
            if (score >= 0.9 && support >= 50)
                newState = ReputationState.ConfirmedBad;
            else if (score <= 0.4 || support < 10)
                newState = ReputationState.Neutral;
            break;

        case ReputationState.ConfirmedBad:
            // Can demote to Suspect (requires MORE evidence to forgive)
            if (score <= 0.7 && support >= 100)
                newState = ReputationState.Suspect;
            break;
    }

    // ... log state change and return ...
}
```

Let op de asymmetrie: het is makkelijker om geblokkeerd te worden dan gedeblokkeerd. `ConfirmedBad → Suspect` vereist 100 ondersteuning, terwijl `Neutral → Suspect` Dit is opzettelijk - het is moeilijker te vergeven dan te vermoeden.

### Time Decay - Exponentiële Vergeten

Als patronen stil gaan, vervallen ze in de richting van neutraal:

```csharp
// From PatternReputation.cs:334-361
public PatternReputation ApplyTimeDecay(PatternReputation reputation)
{
    if (reputation.IsManual)
        return reputation;

    var hoursSinceLastSeen = (DateTimeOffset.UtcNow - reputation.LastSeen).TotalHours;

    if (hoursSinceLastSeen < 1)
        return reputation;  // Too recent to decay

    // Score decay toward prior (0.5 = neutral)
    // new_score = old_score + (prior - old_score) × (1 - e^(-Δt/τ))
    var scoreDecayFactor = 1 - Math.Exp(-hoursSinceLastSeen / _options.ScoreDecayTauHours);
    var newScore = reputation.BotScore + (0.5 - reputation.BotScore) * scoreDecayFactor;

    // Support decay
    // new_support = old_support × e^(-Δt/τ)
    var supportDecayFactor = Math.Exp(-hoursSinceLastSeen / _options.SupportDecayTauHours);
    var newSupport = reputation.Support * supportDecayFactor;

    return reputation with
    {
        BotScore = Math.Clamp(newScore, 0, 1),
        Support = newSupport
    };
}
```

De standaard tijdconstanten:

- **Score verval τ**: 168 uur (7 dagen) - scores bewegen 63% naar neutraal na een week van inactiviteit
- **Steunbederf τ**: 336 uur (14 dagen) - het vertrouwen daalt 63% na twee weken

Dit betekent dat een bevestigde-slechte IP die een maand stil gaat uiteindelijk terug zal vallen naar Neutraal. Niet omdat het hervormd - omdat het bewijs werd oud.

## De onderhoudsdienst voor achtergronden

De `ReputationMaintenanceService` voert drie periodieke taken uit:

```csharp
// From ReputationMaintenanceService.cs:48-129
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    _logger.LogInformation("Reputation maintenance service starting");

    // Load persisted reputations on startup
    await _cache.LoadAsync(stoppingToken);

    var decayInterval = TimeSpan.FromMinutes(60);    // Hourly decay sweep
    var gcInterval = TimeSpan.FromHours(24);          // Daily garbage collection
    var persistInterval = TimeSpan.FromMinutes(5);   // Persist every 5 minutes

    var lastDecay = DateTimeOffset.UtcNow;
    var lastGc = DateTimeOffset.UtcNow;
    var lastPersist = DateTimeOffset.UtcNow;

    while (!stoppingToken.IsCancellationRequested)
    {
        await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        var now = DateTimeOffset.UtcNow;

        // Decay sweep: push stale scores toward neutral
        if (now - lastDecay >= decayInterval)
        {
            await _cache.DecaySweepAsync(stoppingToken);
            lastDecay = now;
        }

        // Garbage collection: remove old neutral patterns
        if (now - lastGc >= gcInterval)
        {
            await _cache.GarbageCollectAsync(stoppingToken);
            lastGc = now;

            var stats = _cache.GetStats();
            _logger.LogInformation(
                "Reputation stats: {Total} patterns, {Bad} bad, {Suspect} suspect",
                stats.TotalPatterns, stats.ConfirmedBadCount, stats.SuspectCount);
        }

        // Persistence: save to SQLite
        if (now - lastPersist >= persistInterval)
        {
            await _cache.PersistAsync(stoppingToken);
            lastPersist = now;
        }
    }

    // Final persist on shutdown
    await _cache.PersistAsync(CancellationToken.None);
}
```

De afvalverzamelaar verwijdert patronen die:

- 90+ dagen oud
- Ondersteuning ≤ 1,0
- In neutrale toestand

Dit voorkomt dat het in-geheugen woordenboek ongebonden groeit met behoud van waardevolle geleerde patronen.

## SQLite is geen grap - het is perfect hier

Veel ontwikkelaars bereiken reflexief voor PostgreSQL of Redis. Maar voor dit patroon is SQLite ideaal:

1. **Write-behind elimineert knelpunten** - SQLite's single-writer beperking doet er niet toe wanneer schrijven worden gestapeld
2. **Lokale opslag** - geen netwerk latentie, geen verbinding zwembaden
3. **Nul configuratie** - gewoon een bestandspad
4. **Perfect voor edge implementatie** - loopt op een Raspberry Pi
5. **Draagbaar** - de database is slechts een bestand dat u kunt kopiëren rond

Het schema is minimaal:

```sql
CREATE TABLE IF NOT EXISTS learned_weights (
    signature_type TEXT NOT NULL,
    signature TEXT NOT NULL,
    weight REAL NOT NULL,
    confidence REAL NOT NULL,
    observation_count INTEGER NOT NULL DEFAULT 1,
    first_seen TEXT NOT NULL,
    last_seen TEXT NOT NULL,
    PRIMARY KEY (signature_type, signature)
);

CREATE INDEX IF NOT EXISTS idx_signature_type ON learned_weights(signature_type);
CREATE INDEX IF NOT EXISTS idx_confidence ON learned_weights(confidence);
CREATE INDEX IF NOT EXISTS idx_last_seen ON learned_weights(last_seen);
```

Als je grotere schaal nodig hebt, wissel dan naar PostgreSQL. Als je HA of replicatie nodig hebt, wissel dan naar Redis of een gedistribueerde cache. De architectuur verandert niet - alleen de verbindingsstring. SQLite is de standaard voor randimplementatie, geen religie.

## De DiSE Tie-In - Failure als Evolution

Als [DiSE](/blog/dise-architecture-overview) is de volledige evolutionaire motor, dit cache patroon is de mitochondria - het kleinste stuk dat zich nog steeds gedraagt als evolutie onder beperking.

Dit patroon implementeert DiSE principes op het meest minimale niveau:

- **Hulpbronbeperking** → Selectiedruk
- **LRU-uitzetting** → Natuurlijke selectie (overlevenden zijn de sterkste)
- **Verval van de tijd** → Vergeten maakt aanpassing mogelijk
- **EMA-updates** → Verandering door observatie
- **Hysterese** → Stabiliteit door weerstand tegen verandering

Een volle cache is geen mislukking. **evolutionaire druk**. Het systeem zelf-optimaliseert: hete handtekeningen blijven, koude handtekeningen uitzetten, en het gedrag geheugen convergeert naar wat er eigenlijk toe doet.

Geen ML-training, geen externe modellen, alleen architectuur die zich gedraagt als een levend systeem.

## Conclusie - Simple Structures, Emergent Gedrag

Het hele patroon komt neer op:

1. **Cache is de bron van de waarheid voor lezen** - submilliseconde toegang
2. **Schrijft update cache onmiddellijk, blijven later** - geen blokkering van I/O
3. **Schuiftijd zorgt voor automatische LRU** - ingebouwd in .NET
4. **Gebonden grootte zorgt voor selectiedruk** - overflow scherpt de focus
5. **Achtergrond flush houdt SQLite synchroon** - uiteindelijke consistentie is prima
6. **Tijdsverlies maakt vergeten mogelijk** - oud bewijs verdwijnt

Uw kleine gedragswinkel gedraagt zich meer als een levend systeem dan CRUD. Het herinnert zich wat belangrijk is, vergeet wat niet, en wordt beter onder druk.

Minimale architectuur → opkomende correctheid.
Overflow → betere focus.
Druk → stabiliteit.

Als je dit in actie wilt zien, controleer dan [meestallucid.botdetection](/blog/botdetection-introduction) - en de [DiSE architectuurreeks](/blog/dise-architecture-overview) voor de diepere filosofie.

## Links

- [Inleiding Botdetectie](/blog/botdetection-introduction) - het systeem dat deze patronen gebruikt
- [Overzicht van de DiSE-architectuur](/blog/dise-architecture-overview) - gecontroleerde evolutie door druk
- [Moderne CQRS en Event Sourcing](/blog/moderncqrsandeventsourcing) - het volledige patroon (wanneer u het nodig heeft)
- [GitHub: meestal lucid.botdetection](https://github.com/scottgal/mostlylucid.nugetpackages) - volledige broncode
- [Pakket nuGet](https://www.nuget.org/packages/mostlylucid.botdetection/) - installeer het