Skip to the content.

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

Implementation notes

2026-08-14

src/agent/Quotas/QuotaService.cs — четыре исправления:

  1. 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.

  2. CleanOldEntries → PruneOldEntries (line 193-208): новая in-place версия: while (TryPeek && head < cutoff) TryDequeue. Никаких dummy keys, никаких подмен references — bucket остаётся той же ConcurrentQueue, а ссылки не утекают.

  3. MakeStatus signature fix (line 342): добавлен параметр QuotaScope scope; раньше хардкодил QuotaScope.Agent для всех entries, из-за чего GetStatus(Skill, ...) возвращал QuotaStatus { Scope = Agent, ... }. Все вызовы из GetStatus(scope, ...) обновлены, чтобы передавать реальный scope.

  4. 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:

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:

src/agent/Config/AppConfig.cs — QuotasConfig расширен тремя полями:

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 тестов:

tests/Hercules.Agent.Tests/Quotas/DistributedQuotaServiceTests.cs — 6 тестов:

Validation

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

Risks / Rollback

Distributed quotas добавляют сетевую задержку на каждый CheckQuotas; измерить p99 и сделать fallback на in-memory при недоступности store. Rollback: DistributedEnabled=false.