Task 73 — Outbox synced-state and bounded-queue prune fix
Phase: 5
Initiative: 45
Status: done
Owner: —
Slug: outbox-synced-fix
Goal
SqliteOutboxStore.MarkSyncedAsync (Offline/SqliteOutboxStore.cs:139-143) делает DELETE FROM outbox_items WHERE item_id = $iid вместо UPDATE ... SET status='Synced'. Из-за этого PruneSyncedToCapAsync (:202-219) и GetSyncedCountAsync (:221-226) всегда возвращают 0 — bounded-queue prune-логика мертва. DeleteOlderThanAsync (:198) удаляет по created_at < cutoff независимо от статуса, удаляя in-flight Pending items. Цель — корректно хранить Synced-записи для cap-pruning и TTL-retention.
Acceptance criteria
Sub-tasks
Offline/SqliteOutboxStore.cs:139-143— изменитьMarkSyncedAsyncнаUPDATE outbox_items SET status='Synced', synced_at=@now WHERE item_id=@iid(не DELETE).Offline/SqliteOutboxStore.cs:198— изменитьDeleteOlderThanAsyncнаDELETE FROM outbox_items WHERE created_at < @cutoff AND status IN ('Synced','Failed')(не удалять Pending).- Добавить колонку
synced_at TEXT NULL+ индексCREATE INDEX IF NOT EXISTS idx_outbox_status_synced ON outbox_items(status, synced_at). Offline/SqliteOutboxStore.cs:62-81—EnqueueAsyncprune-логика теперь работает: когдаGetSyncedCountAsync>PruneSyncedThreshold, вызыватьPruneSyncedToCapAsync(удалять oldest Synced доPruneSyncedKeep).Offline/OfflineSyncService.cs:104-112— добавить backoff между retry одного item:Task.Delay(BackoffBase * 2^retryCount, ct)перед следующей попыткой; max backoff 5 мин.Offline/OfflineSyncService.cs:158-163— исправить closure:OnReconnectedhandler должен использовать свежийCancellationTokenSource, не capturedstoppingToken(который cancelled после shutdown).Offline/OfflineSyncService.cs:246-256— вынести hardcodedDeadline = UtcNow.AddMinutes(5)в configOfflineSyncConfig.DefaultDeadlineMinutes.Offline/SyncItem.cs:46— убрать double ULID generation (factory methods уже генерируют; убрать default initializer).- Unit-тест:
MarkSyncedAsync→ row остаётся со status=’Synced’;GetSyncedCountAsyncвозвращает 1. - Unit-тест:
EnqueueAsyncпри достижении cap вызывает prune;GetSyncedCountAsyncпосле prune <=PruneSyncedKeep. - Unit-тест:
DeleteOlderThanAsyncне удаляет Pending items. dotnet build+dotnet testpass.
Completion note
SqliteOutboxStore теперь корректно хранит Synced-записи (UPDATE вместо DELETE),
что вернуло к жизни PruneSyncedToCapAsync и GetSyncedCountAsync — bounded-queue
prune-логика больше не мертва. Добавлены synced_at колонка + индекс
idx_outbox_status_synced; DeleteOlderThanAsync сохраняет Pending-строки от
TTL-вытеснения. OfflineSyncConfig получил PruneSyncedThreshold/PruneSyncedKeep,
DefaultDeadlineMinutes, MaxBackoffMs. В OfflineSyncService появился
exponential backoff между retry одного item (cap 5 мин), OnReconnected теперь
использует свежий CancellationTokenSource через linked CTS, что корректно
обрабатывает shutdown. Убран default-инициализатор OutboxItem.ItemId,
исключающий двойную генерацию ULID.
Validation: dotnet build src/agent/Hercules.csproj (0 errors), dotnet build
src/agent/Hercules.WebApi/Hercules.WebApi.csproj (0 errors), dotnet test
--filter FullyQualifiedName~Offline (26 passed, 0 failed). Pre-existing failures
(OtelService, NumericValidator, WasmTool, RedisTaskQueue, BudgetGuard) подтверждены
на baseline (git stash) — к данной задаче не относятся.
Implementation notes
- Добавлена колонка
synced_at TEXT(UTC ISO-8601) — NULL для Pending/Failed; выставляется вMarkSyncedAsync. - Новый индекс
idx_outbox_status_syncedускоряет выборкиWHERE status='Synced' ORDER BY synced_atдля prune. PruneSyncedKeep(default 500) иPruneSyncedThreshold(default 500) добавлены вOfflineSyncConfig.RetryBaseDelayMs(default 1000) +MaxBackoffMs(default 300_000 = 5 min) — exponential backoffBase * 2^retryCountс cap.OnReconnectedhandler создаёт freshCancellationTokenSource, отменяемый при shutdown.DefaultDeadlineMinutes(default 5) перенесён из hardcoded в конфиг.
Scope / Likely files
src/agent/Offline/SqliteOutboxStore.cs, src/agent/Offline/OfflineSyncService.cs, src/agent/Offline/OfflineSyncConfig.cs, src/agent/Offline/SyncItem.cs
Dependencies
- блокирует / опирается на: task_060 — offline-resilience
Risks / Rollback
Хранение Synced-записей увеличивает размер таблицы до prune; убедиться что PruneSyncedKeep (default 500) достаточно мал. Rollback: git revert (вернуть DELETE).
Links
- Backlog: ../backlog.md
- Roadmap (EN): ../../ROADMAP-EN.md
- Roadmap (RU): ../../ROADMAP-RU.md