Skip to the content.

Task 71 — SQLite thread-safety and sync-over-async removal

Phase: 5 Initiative: 45 Status: done Owner: — Slug: sqlite-thread-safety

Goal

SqliteSessionStore делит одну SqliteConnection между всеми async- и sync-вызовами без какой-либо синхронизации. Microsoft.Data.Sqlite не потокобезопасен для конкурентных команд на одном соединении — конкурентные запросы бросают SQLiteException или портят чтения. Дополнительно ~20 sync-обёрток (.GetAwaiter().GetResult()) вызываются из горячих путей (AgentCore.HandleAsyncCore, background services), блокируя thread-pool потоки на async I/O и усугубляя гонку на соединении. Цель — сделать SQLite-доступ потокобезопасным и полностью асинхронным.

Acceptance criteria

Sub-tasks

Follow-up scope (deferred to other tasks)

Эти пункты изначально входили в task_071, но явно перенесены в другие roadmap-задачи, чтобы не размывать скоуп. Треды/зависимости сохранены ниже.

Implementation notes

2026-08-14

Implemented:

  1. src/agent/Storage/SqliteSessionStore.cs — добавлен private readonly SemaphoreSlim _connLock = new(1, 1) + флаг _disposed (Interlocked-protected) для пост-disposal safety:
    • IsHealthy — non-blocking Wait(0) probe; возвращает false если соединение сейчас занято или уже disposed; без GetAwaiter().GetResult()-вызовов async-методов.
    • EnableWalMode (конструктор) и InitSchema/InitCheckpointSchemaAsync (конструктор) — обёрнуты в Wait() (sync) так как вызываются до возврата из ctor.
    • Все 23 публичных *Async метода — каждое тело обёрнуто в await _connLock.WaitAsync(ct); try { ... } finally { _connLock.Release(); }; Task-returning → async Task где требовалось.
    • 20 sync-обёрток сохранены и направляются через тот же lock — caller’ы (Main, TelegramBot, ConsoleUI) остаются safe без рефакторинга их call-стэка.
    • Dispose() / DisposeAsync() — захватывают lock до _conn.Dispose() чтобы in-flight async-команды не получили disposed connection; затем освобождают и Dispose() сам семафор. Interlocked-флаг предотвращает двойной dispose.
    • Store.Connection остаётся публичным (используется SqliteOutboxStore и SkillQualityStore); follow-up: см. task_077.
  2. tests/Hercules.Agent.Tests/Storage/SqliteSessionStoreDirectTests.cs — расширен Concurrency (task_071) регион:
    • Concurrent_LogInteractionAsync_AllPersist — 10 параллельных writes, проверка что все 10 строк в interactions.
    • Concurrent_MixedWrites_AllPersistAcrossTables — 8×3 = 24 параллельных операций (interactions + audit + budget) без потерь.
    • IsHealthy_UnderConcurrentLoad_ReturnsTrue — 50 фоновых writes + 20 синхронных IsHealthy() probe’ов, все возвращают true.
    • Concurrent_SyncWrappers_AllPersist — 10 sync-вызовов через Task.Run; подтверждает что sync-обёртки тоже сериализуются.

Validation:

dotnet build tests/Hercules.Agent.Tests/Hercules.Agent.Tests.csproj
  -> Build succeeded. 0 Warning(s) 0 Error(s)

dotnet test --filter 'FullyQualifiedName~SqliteSessionStoreDirectTests'
  -> Passed! 19/19, 0 Failed, 0 Skipped (3s)

dotnet test (full suite)
  -> Failed: 10, Passed: 1727, Skipped: 0, Total: 1737
  -> 10 failures pre-existing (OtelServiceTests + WasmToolTests + Budget emoji),
     НЕ относятся к task_071; подтверждено через `git stash` (baseline = 10 failed,
     1723 passed — мои 4 новых concurrency-теста добавлены и прошли).

Notes:

Scope / Likely files

src/agent/Storage/SqliteSessionStore.cs, src/agent/Agent/AgentCore.cs, src/agent/Storage/MemoryStore.cs, src/agent/Storage/FileSkillRepository.cs, src/agent/Skills/Quality/SkillQualityStore.cs, src/agent/HerculesBus/Sqlite/SqliteChannelStore.cs, src/agent/HerculesBus/Sqlite/SqliteAgentRegistry.cs

Dependencies

Risks / Rollback

Connection-per-operation увеличивает число открытых файловых дескрипторов; WAL + shared cache минимизирует overhead. SemaphoreSlim сериализует доступ — acceptable для single-node агента, но ограничивает throughput под нагрузкой. Rollback: вернуть sync-обёртки (git revert).