Task 74 — NATS JetStream ack/fail implementation
Phase: 5
Initiative: 45
Status: done
Owner: —
Slug: nats-jetstream-ack
Goal
NatsTaskQueue.AckAsync и FailAsync (Mesh/Backends/Nats/NatsTaskQueue.cs:266-278) — no-ops (await Task.CompletedTask). JetStream ack/nak никогда не отправляется → сообщения redeliver’ятся бесконечно (до MaxDeliver/AckWait). Это критический баг: задача считается “обработанной”, но остаётся в потоке и доставляется снова и снова. Дополнительно MaxDeliver = 10 hardcoded.
Acceptance criteria
Sub-tasks
Mesh/Backends/Nats/NatsTaskQueue.cs:266-278— реализоватьAckAsync: получитьNatsJSMsgиз in-flight tracker по taskId, вызватьmsg.AckAsync().- Реализовать
FailAsync: вызватьmsg.NakAsync()(для retry) илиmsg.AckTerminateAsync(AckOpts{TerminateReason})(для DLQ послеMaxRetries). - Создать
NatsTaskInFlightTracker(internal class):ConcurrentDictionary<taskId, Entry>где Entry хранит action-делегаты (Ack/Nak/Terminate) иNumDelivered.DequeueAsyncсохраняет entry,AckAsync/FailAsyncудаляют. NatsMeshConfig.MaxDeliveryAttempts(default 10) —Mesh/Backends/Nats/NatsTaskQueue.cs:145-148consumer config использует это значение вместо hardcodedMaxDeliver = 10.- Локальный DLQ: создать
NatsTaskDlqStore(JSONL-файл) — после Terminate записать entry в<data>/{StreamPrefix}-dlq.jsonl.GetDeadLetterQueueAsync/RequeueDeadLetterAsyncработают с этим файлом. RequeueDeadLetterAsync— переработать: прочитать JSONL, найти запись, republish в JetStream stream, удалить из файла.- Unit-тесты:
NatsTaskInFlightTracker(Add/Get/Remove/Count/Replace),NatsTaskDlqStore(Append/List/Remove/persistence),NatsTaskQueueconfig defaults (включаяMaxDeliveryAttempts). - Unit-тест:
NatsTaskQueue.AckAsync/FailAsync— no-op когда tracker пуст (не падают с NRE). dotnet build+dotnet testpass.- Integration-тесты с live NATS отложены: требуют testcontainer или live server (отмечены
[Fact(Skip="requires live NATS")]).
Scope / Likely files
src/agent/Mesh/Backends/Nats/NatsTaskQueue.cs, src/agent/Mesh/Backends/Nats/NatsMeshConfig.cs, src/agent/Mesh/Abstractions/ITaskQueue.cs (metadata field)
Dependencies
- блокирует / опирается на: task_068 — nats-jetstream-transport
Implementation notes
2026-08-14
Реализовано:
src/agent/Mesh/Backends/Nats/NatsTaskInFlightTracker.cs (новый файл, 60 строк) — внутренний tracker:
ConcurrentDictionary<string, Entry>keyed by taskIdEntryхранит:TaskId,QueueName,NumDelivered,MeshTask, и 3 action-делегата (AckAsync,NakAsync,TerminateAsync)- Действия капчурируются в
DequeueAsync(гдеNatsJSMsgещё в скоупе), затем вызываются позднее вAckAsync/FailAsync - Action-делегаты вместо хранения
NatsJSMsgнапрямую — позволяет юнит-тестировать без mock NATS-клиента
src/agent/Mesh/Backends/Nats/NatsTaskDlqStore.cs (новый файл, 160 строк) — локальный DLQ на JSONL-файле:
AppendAsync(MeshTask, reason, deliveryCount, queueName, ct)— добавление записиListAsync(queueName?, limit, ct)— фильтрованный списокRemoveAsync(taskId, ct)— атомарное удаление (temp-file + rename)- Thread-safe через
SemaphoreSlim - Файл:
<DataRoot>/{StreamPrefix}-dlq.jsonl(default DataRoot =AppContext.BaseDirectory)
src/agent/Mesh/Backends/Nats/NatsTaskQueue.cs — рефакторинг:
AckAsync(taskId, ct)— lookup in tracker →entry.AckAsync(ct)→ remove from trackerFailAsync(taskId, reason, retry, ct)— еслиretry >= MaxRetriesилиentry.NumDelivered >= MaxDeliveryAttempts:TerminateAsync(AckOpts{TerminateReason=reason})+ append в DLQ файл. Иначе:NakAsync(null, ct)для retryDequeueAsync— после успешного парсинга создаётEntryс капчурированными делегатами и добавляет в tracker- Consumer config:
MaxDeliver = _config.MaxDeliveryAttempts(вместо hardcoded 10) GetDeadLetterQueueAsync/RequeueDeadLetterAsync— работают черезNatsTaskDlqStore(JSONL-файл), legacy JetStream DLQ stream больше не создаётся_connectionстал nullable — null-guard’ы вEnqueueAsync/DequeueAsync/IsHealthyAsync/EnsureStreamAndConsumersAsync- Удалено поле
_dlqStreamName(legacy JetStream DLQ stream больше не используется)
src/agent/Mesh/Backends/Nats/NatsMeshConfig.cs — новые поля:
MaxDeliveryAttempts(default 10)DlqFileName(default пустое → автогенерация{StreamPrefix}-dlq.jsonl)DataRoot(default null →AppContext.BaseDirectory)
Тесты (3 файла, 31 тест):
NatsTaskInFlightTrackerTests(8 тестов): AddOrReplace, Get, Remove, Count, multi-entry, action delegate invocationNatsTaskDlqStoreTests(10 тестов): Append/List, filter by queue, limit, no-file, remove, persistence, malformed lines, append after removeNatsTaskQueueTests(расширен, 7 новых тестов): config defaults дляMaxDeliveryAttempts/DlqFileName/DataRoot,AckAsync/FailAsyncno-op для unknown tasks, post-dispose exceptions,DlqFilePathcomputation,InFlightproperty exposure- Все 31 новый тест проходит.
Validation:
dotnet build src/agent/Hercules.csproj -c Release— 0 errorsdotnet test --filter "NatsTaskInFlightTrackerTests|NatsTaskDlqStoreTests|NatsTaskQueueTests"— 31/31 passed- Full suite: 1769/1779 passed (10 pre-existing failures: OtelService×5, BudgetGuard×1, NumericValidator×2, BusHttpServer×1, RedisTaskQueue.EnqueueAsync_StoresTaskMetadata×1 — все pre-existing, не связаны с task_074)
Изменённые файлы:
src/agent/Mesh/Backends/Nats/NatsMeshConfig.cs(добавлены 3 поля)src/agent/Mesh/Backends/Nats/NatsTaskQueue.cs(~250 строк логики изменено, 130 строк удалено/упрощено)src/agent/Mesh/Backends/Nats/NatsTaskInFlightTracker.cs(новый)src/agent/Mesh/Backends/Nats/NatsTaskDlqStore.cs(новый)tests/Hercules.Agent.Tests/Mesh/Backends/Nats/NatsTaskInFlightTrackerTests.cs(новый)tests/Hercules.Agent.Tests/Mesh/Backends/Nats/NatsTaskDlqStoreTests.cs(новый)tests/Hercules.Agent.Tests/Mesh/Backends/Nats/NatsTaskQueueTests.cs(расширен)docs/roadmap/tasks/completed/task_074.md(этот файл)
Risks / Rollback
NATS client API может отличаться между версиями; проверить совместимость с установленной NATS.Net package. Rollback: вернуть no-ops (но баг останется). Если NATS backend не используется в production — понизить priority.
Links
- Backlog: ../backlog.md
- Roadmap (EN): ../../ROADMAP-EN.md
- Roadmap (RU): ../../ROADMAP-RU.md