Task 72 — QuotaService cleanup fix and distributed quotas
Phase: 5
Initiative: 45
Status: done
Owner: —
Slug: quota-cleanup-fix
Goal
QuotaService.CleanOldEntries (Quotas/QuotaService.cs:193-206) сломан: он создаёт новый ConcurrentBag под dummy-ключом bucket.GetHashCode().ToString() вместо реального ключа basket. Оригинальный basket никогда не очищается и растёт безгранично → memory leak + некорректные счётчики rate-limit со временем. Дополнительно квоты хранятся in-memory и сбрасываются при рестарте процесса; для fleet-развёртываний нужна опциональная распределённая координация через IMeshStateStore.INCR.
Acceptance criteria
Sub-tasks
Quotas/QuotaService.cs:193-206— исправитьCleanOldEntries: заменить dummy-key swap на корректныйAddOrUpdateна реальном ключе basket, ИЛИ перейти наConcurrentQueue<DateTime>+ периодический prune поTryDequeueпока head < cutoff.- Добавить периодический background cleanup (timer 60s или
BackgroundService) который вызываетCleanOldEntriesдля всех baskets, а не только on-demand при insert. Quotas/QuotaService.cs:342— исправитьMakeStatus: использовать реальныйQuotaScopeпереданный вGetStatus, не хардкодитьQuotaScope.Agent.Quotas/QuotaService.cs:17— заменитьConcurrentBag<DateTime>(stack, O(n) Count) наConcurrentQueue<DateTime>(FIFO, O(1) peek для cleanup).- Добавить опциональный
DistributedQuotaService(flagQuotas.DistributedEnabledв config): counters черезIMeshStateStore.IncrementAsync($"quota:{scope}:{id}:{bucket}", 1, ttl: windowSec), rate-check черезGetAsync+ локальный sliding window. Fallback на in-memory еслиIMeshStateStoreнедоступен. Config/AppConfig.cs— добавитьQuotasConfig.DistributedEnabled(default false),QuotasConfig.DistributedKeyPrefix(default “quota:”).- Unit-тест: basket не растёт после 1000 inserts + cleanup → Count <= maxBucketSize.
- Unit-тест:
MakeStatus(Skill, "skill-1")возвращаетQuotaScope.Skill, неAgent. - Unit-тест:
DistributedQuotaServiceувеличивает counter через mockIMeshStateStoreи корректно считает rate. dotnet build+dotnet testpass.
Implementation notes
2026-08-14
src/agent/Quotas/QuotaService.cs — четыре исправления:
-
ConcurrentBag → ConcurrentQueue (line 17-22): заменил
ConcurrentDictionary<string, ConcurrentBag<DateTime>>наConcurrentDictionary<string, ConcurrentQueue<DateTime>>.ConcurrentBag.Count— O(n), order undefined, поэтому старыйCleanOldEntriesне мог нормально prune.ConcurrentQueue— FIFO с O(1)Enqueue/TryDequeue/TryPeekи тривиальным head-peek для cleanup. -
CleanOldEntries→PruneOldEntries(line 193-208): новая in-place версия:while (TryPeek && head < cutoff) TryDequeue. Никаких dummy keys, никаких подмен references — bucket остаётся той жеConcurrentQueue, а ссылки не утекают. -
MakeStatussignature fix (line 342): добавлен параметрQuotaScope scope; раньше хардкодилQuotaScope.Agentдля всех entries, из-за чегоGetStatus(Skill, ...)возвращалQuotaStatus { Scope = Agent, ... }. Все вызовы изGetStatus(scope, ...)обновлены, чтобы передавать реальный scope. -
SweepAllBuckets(new public method): обходит все buckets и прорeзает expired entries. Вызывается периодически изQuotaCleanupBackgroundService.
src/agent/Quotas/QuotaCleanupBackgroundService.cs — new BackgroundService. Каждые Quotas.CleanupIntervalSeconds (default 60) зовёт QuotaService.SweepAllBuckets и логирует количество вычищенных entries. Best-effort: exception в sweep не валит host.
src/agent/Quotas/DistributedQuotaService.cs — new decorator над IQuotaService. При Quotas.DistributedEnabled = true:
RecordUsageдополнительно вызываетIMeshStateStore.IncrementAsync($"quota:{scope}:{scopeId}:{type}", 1, ttl: windowSec)(fire-and-forget, async);GetRateLimitInfoчитает distributed counter и пересчитываетRemaining = max(0, limit - distributed);- При недоступности store — fallback на in-memory (warning в лог).
src/agent/Mesh/Abstractions/IMeshStateStore.cs — добавлен optional параметр TimeSpan? ttl = null в IncrementAsync (backward compatible, default null сохраняет старое поведение). Это нужно для sliding-window семантики distributed counters.
src/agent/Mesh/InProcess/InProcessMeshStateStore.cs + Redis/Postgres/Nats backends — реализована поддержка TTL на IncrementAsync:
- InProcess:
ExpiresAtобновляется на каждом increment (sliding window). - Redis: после
StringIncrementAsyncзовётсяKeyExpireAsync(prefixedKey, ttl). - Postgres:
expires_atпишется в UPSERT сCOALESCE(@expiresAt, state.expires_at). - NATS:
ExpiresAtпробрасывается вCompareAndSetAsync(key, value, current.Version, ttl).
src/agent/Config/AppConfig.cs — QuotasConfig расширен тремя полями:
DistributedEnabled(defaultfalse) — глобальный feature flag;DistributedKeyPrefix(default"quota:") — префикс distributed counter keys;CleanupIntervalSeconds(default60) — период periodic sweep.
src/agent/Program.cs — DI: зарегистрирован QuotaService как concrete singleton (для QuotaCleanupBackgroundService), а IQuotaService резолвится в DistributedQuotaService если DistributedEnabled && IMeshStateStore доступен, иначе в QuotaService. Добавлен services.AddHostedService<QuotaCleanupBackgroundService>().
Tests
tests/Hercules.Agent.Tests/Quotas/QuotaServiceCleanupFixTests.cs — 7 тестов:
GetStatus_*Scope_*ReportsScope(4 теста) — проверяют чтоMakeStatusвозвращает правильный scope (Skill, User, Tenant, Agent);RecordUsage_AfterManyInserts_AndSweep_BucketStaysBounded— реплей оригинального бага: 1000 inserts + sweep → bucket ≤ 1;SweepAllBuckets_ReturnsNonZero_AfterExpiry— sweep действительно вычищает entries;SweepAllBuckets_NoExpiry_ReturnsZero— sweep не вычищает свежие entries.
tests/Hercules.Agent.Tests/Quotas/DistributedQuotaServiceTests.cs — 6 тестов:
RecordUsage_BumpsDistributedCounter_WhenRateLimitType— 3 calls → counter = 3 вInProcessMeshStateStore;RecordUsage_DoesNotBumpDistributedCounter_ForNonRateLimitType— TokensPerDay не пишется в distributed store;GetRateLimitInfo_ReadsDistributedCounter_AndComputesRemaining— pre-seeded counter=80, limit=100 → remaining=20;CheckQuotas_DelegatedToInner— делегирование работает;Disabled_DoesNotCallStore—DistributedEnabled=false→ store не дёргается;StoreThrows_FallsBackToLocal— store down → local counter всё равно обновляется.
Validation
dotnet build src/agent/Hercules.csproj -c Release— 0 errors, 17 warnings (pre-existing, не от этой задачи).dotnet test --filter "FullyQualifiedName~Quota"— 40/40 passed (27 QuotaService + 7 cleanup-fix + 6 distributed).- Full suite: 1741/1750 (9 pre-existing failures: OtelService×5, BudgetGuard×1, NumericValidator×2, RedisTaskQueue×1).
Scope / Likely files
src/agent/Quotas/QuotaService.cs, src/agent/Quotas/DistributedQuotaService.cs (new), src/agent/Quotas/Models.cs, src/agent/Config/AppConfig.cs, src/agent/Program.cs
Dependencies
- блокирует / опирается на: task_056 — rate-limits-quotas
- блокирует / опирается на: task_066 — mesh-backends-abstraction (для distributed)
- опционально опирается на: task_067 — redis-coordination-backend, task_069 — postgres-shared-state
Risks / Rollback
Distributed quotas добавляют сетевую задержку на каждый CheckQuotas; измерить p99 и сделать fallback на in-memory при недоступности store. Rollback: DistributedEnabled=false.
Links
- Backlog: ../backlog.md
- Roadmap (EN): ../../ROADMAP-EN.md
- Roadmap (RU): ../../ROADMAP-RU.md