feat(ingestion): add async Kafka writes with retry and DLQ (#29) - #46
feat(ingestion): add async Kafka writes with retry and DLQ (#29)#46hardikkaurani wants to merge 9 commits into
Conversation
Uday9909
left a comment
There was a problem hiding this comment.
Nice work here. Moving /ingest to a bounded queue with a worker pool, exponential backoff, and a DLQ directly fixes the real problem in #29: the old handler held the request open for up to 10s against a dead Kafka and then dropped the entry. The core design is right and CI is green. A few things to fix before this merges.
-
Don't allow zero workers. In getWorkerCount, the check is n >= 0, so INGEST_WORKERS=0 is accepted. With no workers running, the service returns 202 Accepted for every log and silently drops all of them. That's the worst possible failure mode: the client is told it was accepted and nothing is ever written. Require n >= 1 and fall back to the default on 0. The 429 test currently depends on workers=0 to saturate the queue; rework it to use a worker whose WriteMessages blocks (for example on a channel) so the queue stays full, and keep the worker count at the default.
-
Fix the shutdown path. In Shutdown you close(s.queue) immediately after http.Shutdown(ctx). If http.Shutdown returns its context deadline error, in-flight handlers may still be running and can send on the now-closed channel, which panics with 'send on closed channel'. Separately, the drain goroutine (s.wg.Wait) can outlive Shutdown when the context expires, and main then calls writer.Close() and dlqWriter.Close() while workers are still mid-write. That's a real race at shutdown. The numbers make it worse: a full 10000-job queue with 4 workers against a down broker takes roughly 27 hours to drain (about 40s per job with the 10s write timeouts plus backoff), so the 15s shutdown context in main expires immediately and the goroutine is left racing writer.Close(). Consider stopping workers via a done channel and select instead of closing the queue, and have main wait for the waitgroup before closing the writers.
-
Restore the three deleted tests. TestHealthz_Unhealthy, TestHealthz_NilWriter, and TestTimestampAutoFill were removed, but the code they cover (healthz 503 path, nil writer handling, timestamp autofill) is untouched by this PR and those tests still pass. Deleting them is unrelated scope creep and it drops real coverage. Port TestTimestampAutoFill to the async model: the write is now async, so wait for the mock writer to record the message before asserting on it.
-
Add a test for the actual async path. TestHandleIngest_Success only asserts the 202 response, and the two worker tests call processJob directly. Nothing exercises workerLoop actually ranging over s.queue. One test that enqueues through the handler and then asserts the mock writer received the message would cover the wiring that currently has no test.
-
ingestion_duration_seconds no longer measures what its help text claims. It now records enqueue time, which is always near zero, and the success label means 'queued' rather than 'written'. Either rename it or add a write-latency histogram in the worker loop, otherwise dashboards that alert on this metric are silently measuring the wrong thing.
Minor: computeBackoff caps at 2000ms but with maxRetries=3 the largest delay actually reached is 400ms, so the cap is dead code and the PR description overstates the backoff range. Drop the cap or extend the retry count so it is real. The hardcoded raw-logs / raw-logs-dlq topics are consistent with the existing newKafkaLogWriter, so that's fine, just worth a comment noting the DLQ topic is fixed.
The feature itself is solid and I'm happy to approve once the shutdown handling and the zero-worker case are addressed.
|
Thanks for the brutal and thorough review @Uday9909! 🙌 Just pushed commit
All 14 unit test suites, |
Summary
Addresses Issue #29 (Roadmap Section 1b).
Currently,
handleIngest(ingestion-service/main.go) writes synchronously vias.Writer.WriteMessageswith a 10s context timeout. During a Kafka broker outage or network degradation, incoming/ingestrequests block for up to 10s before failing with HTTP 504/500, permanently dropping the log entry without retries, buffering, or Dead Letter Queue (DLQ) processing.This PR decouples HTTP
/ingestresponse latency from Kafka round-trips by introducing a bounded in-memory queue, background worker pool, exponential backoff retries, and DLQ routing.Key Changes & Architecture
/ingestvalidates requests and enqueuesLogJobinto a bounded Go channel (INGEST_QUEUE_CAPACITY, default10000).{"status":"accepted","message":"log queued for ingestion"}) immediately upon successful enqueueing.selectreturns HTTP 429 Too Many Requests ({"error":"ingestion queue full, retry later"}) without blocking HTTP handler execution or spilling memory.INGEST_WORKERS, default4) processes queued jobs concurrently.DLQPayload(preservingoriginal_log,original_topic,retry_count,failure_reason, andfailed_at) and sent to theraw-logs-dlqtopic via a dedicated DLQ producer.logs_dlq_totalPrometheus counter metric (service,reason).dlq_write_failures_totalcounter and logs error safely without deadlocking or crashing the worker.Server.Shutdown(ctx)stops the HTTP listener first, closess.queue, and waits for workers to drain remaining jobs viasync.WaitGroupup to the context timeout.logs_ingested_totalandingestion_duration_seconds.logs_dlq_totalcounter (service,reason) anddlq_write_failures_totalcounter (service).Reliability & Limitations
kill -9) cannot survive a process restart.Verification & Testing
go fmt ./...-> PASSEDgo vet ./...-> PASSEDgo test -v ./...-> PASSED (All unit tests green)ingestion-service/main_test.gocovering HTTP 202 enqueuing, HTTP 429 backpressure, worker retries, backoff progression, DLQ routing, DLQ failure resilience, and shutdown draining.Closes feat(ingestion): async Kafka writes with retry + DLQ topic (roadmap 1b) #29