Skip to content

Commit c3fe7d2

Browse files
authored
Durability: ack-after-delivery + drain on shutdown, proven by an e2e suite (#16)
* Ack after delivery and drain on shutdown (#1) Close the durability gap: rtbeat previously returned HTTP 200 before the event was delivered and closed the publisher without draining, so an upstream sender that treats the 200 as durable (e.g. rxtx removing a batch from its on-disk queue) could lose in-flight events on a restart. Delivery: - POST /in now publishes the batch with GuaranteedSend and waits for the output to acknowledge it before responding. Per-batch correlation uses a batchAck token carried in each event's Private field, surfaced via acker.EventPrivateReporter. On ack -> 200; on timeout -> 504 so the sender keeps its durable copy and retries (pair with a deterministic doc id downstream to dedupe). The ack budget is the previously-unused config "timeout" (default 5s). Shutdown: - ClientConfig gains WaitClose; Stop signals and Run drains in the right order: stop HTTP intake -> let in-flight handlers finish -> close the client (drains outstanding events up to the new "shutdown_timeout", default 30s). Closing before intake stops would let a late batch publish into a closing pipeline and be lost. Also: - Remove the stray leading zero-value event published with every batch. - Clamp non-positive timeout / shutdown_timeout to defaults (a zero ack timeout would 504 everything; a zero shutdown timeout would silently disable the drain). - Tests cover delivered/timeout/empty/bad-JSON handler paths, partial and multi-batch ack resolution, the lifecycle, and config clamping (beater 92%); config example, docs, and CHANGELOG updated. Closes #1 * Add end-to-end durability tests (#1) Prove the #1 durability guarantees against real components instead of fakes: a black-box suite (build tag e2e) builds and runs the actual rtbeat binary shipping to a real libbeat logstash output backed by a controllable go-lumber lumberjack server. Tests: - AckAfterDelivery: 200 only after a real over-the-wire ack. - WaitsForAckBeforeOK: delayed ack proves the 200 waits for delivery (asserts elapsed >= ack delay), not acked on receipt. - StallReturns504: a stalled output yields 504, and the event still reached the output (so the sender retries rather than dropping it). - MultiMessageFragmentedAck: with bulk_max_size=1 a single /in batch is acked in fragments across multiple callbacks over the wire, exercising the per-batch ack correlation. - DrainsInFlightOnShutdown: an in-flight request is delivered on SIGTERM (graceful drain) and the process exits cleanly. Out of scope (documented in the package doc): hard-crash and cross-restart durability, which rely on sender retry-on-non-2xx and an optional libbeat disk queue. Also: a dedicated e2e CI job, a `make e2e` target, and go-lumber promoted to a direct dependency. The suite is tag-gated, so the default `go test` / `make verify` stay fast and offline. * Replace CHANGELOG contents with a pointer to GitHub Releases Stop hand-maintaining a Keep-a-Changelog file; GoReleaser already generates per-release notes on GitHub Releases. CHANGELOG.md now just points there.
1 parent 1cef2bf commit c3fe7d2

12 files changed

Lines changed: 812 additions & 108 deletions

File tree

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,25 @@ jobs:
7474
fail_ci_if_error: false
7575
verbose: true
7676

77+
e2e:
78+
name: E2E (durability)
79+
runs-on: ubuntu-latest
80+
steps:
81+
- name: Checkout
82+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
83+
84+
- name: Set up Go
85+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
86+
with:
87+
go-version-file: go.mod
88+
cache: true
89+
90+
- name: Download dependencies
91+
run: go mod download
92+
93+
- name: Run end-to-end durability tests
94+
run: go test -tags e2e -count=1 -timeout 300s ./test/e2e/...
95+
7796
build:
7897
name: Build
7998
runs-on: ubuntu-latest

CHANGELOG.md

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,6 @@
11
# Changelog
22

3-
All notable changes to this project are documented here. The format is based on
4-
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
3+
Release notes live on **GitHub Releases**: https://github.com/txn2/rtbeat/releases
54

6-
## [Unreleased]
7-
8-
### Changed
9-
10-
- Migrated from glide/GOPATH vendoring to Go modules (`go.mod`), targeting Go 1.26.
11-
- Upgraded Elastic libbeat to v7.17.29 (from the v7.0.0-alpha line) and refreshed all direct
12-
dependencies (gin, prometheus client, zap).
13-
- Replaced the libbeat-generated Makefile and Travis CI with a `make verify` workflow and GitHub
14-
Actions (CI, CodeQL, OpenSSF Scorecard, Dependabot, docs).
15-
- Reworked the GoReleaser pipeline to v2: static `CGO_ENABLED=0` builds for linux/darwin
16-
(amd64, arm64), Cosign keyless signing, SBOMs, SLSA provenance, and multi-arch Docker images.
17-
18-
### Added
19-
20-
- `golangci-lint` v2 configuration and a clean lint baseline.
21-
- `SECURITY.md`, `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, issue/PR templates, `CODEOWNERS`,
22-
`codecov.yml`, and MkDocs documentation.
23-
24-
### Notes
25-
26-
- The rxtx `MessageBatch` wire format is unchanged; `txn2/rxtx` is pinned to the prior production
27-
revision and its 2018-era transitive dependencies (`coreos/bbolt`, `satori/go.uuid`) are pinned to
28-
compatible commits.
5+
Each tagged release (`vX.Y.Z`) ships notes generated from the commit history by
6+
GoReleaser, alongside signed artifacts, checksums, SBOMs, and provenance.

Makefile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ test:
6060
@echo "==> go test (race + coverage, matches CI)"
6161
$(GO) test -race -coverprofile=coverage.txt -covermode=atomic ./...
6262

63+
# End-to-end durability tests: build the real binary and drive it against a
64+
# controllable lumberjack (logstash) server. Tag-gated so the default test run
65+
# stays fast and offline.
66+
.PHONY: e2e
67+
e2e:
68+
@echo "==> go test -tags e2e (end-to-end durability)"
69+
$(GO) test -tags e2e -count=1 -timeout 300s ./test/e2e/...
70+
6371
# Install the exact golangci-lint version CI uses, into a local cache.
6472
$(GOLANGCI_LINT):
6573
@echo "==> installing golangci-lint $(GOLANGCI_LINT_VERSION)"
@@ -87,6 +95,7 @@ help:
8795
@echo " verify lint + test + build + tidy-check + action pins"
8896
@echo " lint golangci-lint (auto-installs $(GOLANGCI_LINT_VERSION) to .tools/)"
8997
@echo " test go test -race with coverage profile"
98+
@echo " e2e end-to-end durability tests (go test -tags e2e)"
9099
@echo " build CGO_ENABLED=0 go build -o rtbeat ."
91100
@echo " tidy go mod tidy"
92101
@echo " tidy-check fail if go.mod/go.sum are not tidy"

beater/rtbeat.go

Lines changed: 128 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"io"
88
"log"
99
"net/http"
10+
"sync"
11+
"sync/atomic"
1012
"time"
1113

1214
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -45,6 +47,16 @@ func New(_ *beat.Beat, cfg *common.Config) (beat.Beater, error) {
4547
return nil, fmt.Errorf("error reading config file: %v", err)
4648
}
4749

50+
// Guard the durability footguns: a non-positive ack timeout would 504 every
51+
// request, and a non-positive shutdown timeout would silently disable the
52+
// drain. Fall back to the defaults.
53+
if c.Timeout <= 0 {
54+
c.Timeout = config.DefaultConfig.Timeout
55+
}
56+
if c.ShutdownTimeout <= 0 {
57+
c.ShutdownTimeout = config.DefaultConfig.ShutdownTimeout
58+
}
59+
4860
zapCfg := zap.NewProductionConfig()
4961
zapCfg.DisableCaller = true
5062
zapCfg.DisableStacktrace = true
@@ -98,16 +110,30 @@ func (bt *Rtbeat) Run(b *beat.Beat) error {
98110

99111
var err error
100112
bt.client, err = b.Publisher.ConnectWith(beat.ClientConfig{
101-
//PublishMode: beat.GuaranteedSend,
102-
ACKHandler: acker.RawCounting(func(i int) {
103-
bt.logger.Info("Run", zapcore.Field{
104-
Key: "ACKCount",
105-
Type: zapcore.Int32Type,
106-
Integer: int64(i),
107-
})
108-
currentAcks.Set(float64(i))
109-
totalAcks.Add(float64(i))
110-
}),
113+
// GuaranteedSend retries events until the output acknowledges them;
114+
// WaitClose makes Close() (called from Stop) block until in-flight
115+
// events are acked or the shutdown budget elapses, so a graceful
116+
// shutdown drains rather than dropping in-flight events.
117+
PublishMode: beat.GuaranteedSend,
118+
WaitClose: time.Duration(bt.config.ShutdownTimeout) * time.Second,
119+
ACKHandler: acker.Combine(
120+
// Existing ack metrics.
121+
acker.RawCounting(func(i int) {
122+
bt.logger.Info("Run", zapcore.Field{
123+
Key: "ACKCount",
124+
Type: zapcore.Int32Type,
125+
Integer: int64(i),
126+
})
127+
currentAcks.Set(float64(i))
128+
totalAcks.Add(float64(i))
129+
}),
130+
// Per-batch ack correlation: each event carries its *batchAck in
131+
// Private; when all of a batch's events are acked, its waiter is
132+
// released so POST /in can return 200 (delivered).
133+
acker.EventPrivateReporter(func(_ int, data []interface{}) {
134+
resolveBatchAcks(data)
135+
}),
136+
),
111137
})
112138
if err != nil {
113139
return err
@@ -123,7 +149,8 @@ func (bt *Rtbeat) Run(b *beat.Beat) error {
123149
// get a router
124150
r := gin.Default()
125151

126-
r.POST("/in", inHandler(b.Info.Name, bt.logger, bt.client, batches.Inc, func(n int) {
152+
ackTimeout := time.Duration(bt.config.Timeout) * time.Second
153+
r.POST("/in", inHandler(b.Info.Name, bt.logger, bt.client, ackTimeout, batches.Inc, func(n int) {
127154
messages.Add(float64(n))
128155
}))
129156

@@ -164,16 +191,25 @@ func (bt *Rtbeat) Run(b *beat.Beat) error {
164191
},
165192
)
166193

167-
// shutdown the web server
168-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
194+
// Graceful shutdown ordering matters for durability: stop accepting new
195+
// requests first (Shutdown waits for in-flight handlers to finish waiting
196+
// on their acks), THEN close the client. Closing was configured with
197+
// WaitClose + GuaranteedSend, so Close drains outstanding events until
198+
// acked or the shutdown budget elapses. Closing before intake stops would
199+
// let a late batch publish into a closing pipeline and be lost.
200+
shutdownTimeout := time.Duration(bt.config.ShutdownTimeout) * time.Second
201+
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
169202
_ = srv.Shutdown(ctx)
170203
cancel()
204+
_ = bt.client.Close()
171205
return nil
172206
}
173207

174-
// Stop the beat
208+
// Stop signals the beat to shut down. The actual drain (stop intake, finish
209+
// in-flight handlers, then drain and close the publisher) happens in Run's
210+
// shutdown sequence to guarantee the ordering.
175211
func (bt *Rtbeat) Stop() {
176-
_ = bt.client.Close()
212+
bt.logger.Info("Stop", zap.String("state", "shutting down"))
177213
close(bt.done)
178214
}
179215

@@ -183,32 +219,73 @@ type eventPublisher interface {
183219
PublishAll([]beat.Event)
184220
}
185221

222+
// batchAck tracks the outstanding acknowledgements for a single published
223+
// batch. done is closed once every event in the batch has been acked by the
224+
// output, releasing the HTTP handler waiting on delivery.
225+
type batchAck struct {
226+
remaining int64
227+
done chan struct{}
228+
once sync.Once
229+
}
230+
231+
func newBatchAck(n int) *batchAck {
232+
return &batchAck{remaining: int64(n), done: make(chan struct{})}
233+
}
234+
235+
// ack records n acknowledged events and releases waiters once the batch is
236+
// fully delivered.
237+
func (a *batchAck) ack(n int) {
238+
if atomic.AddInt64(&a.remaining, -int64(n)) <= 0 {
239+
a.once.Do(func() { close(a.done) })
240+
}
241+
}
242+
243+
// resolveBatchAcks groups a slice of acked events' Private values by their
244+
// owning batch and releases each batch that is now fully delivered. The output
245+
// may ack events from several batches in a single callback, and may split a
246+
// batch's acks across callbacks; grouping per *batchAck handles both. Non
247+
// *batchAck / nil entries are ignored.
248+
func resolveBatchAcks(data []interface{}) {
249+
counts := make(map[*batchAck]int, len(data))
250+
for _, d := range data {
251+
if a, ok := d.(*batchAck); ok && a != nil {
252+
counts[a]++
253+
}
254+
}
255+
for a, n := range counts {
256+
a.ack(n)
257+
}
258+
}
259+
186260
// buildEvents converts an rxtx MessageBatch into the beat.Event slice rtbeat
187-
// publishes. Each message becomes one event carrying the original message under
188-
// "rxtxMsg" alongside "type" and "clientIp". The slice is pre-sized with one
189-
// leading zero-value event; this is long-standing behavior, preserved here
190-
// intentionally (changing it is tracked separately).
191-
func buildEvents(beatName, clientIP string, msg *rtq.MessageBatch) []beat.Event {
192-
events := make([]beat.Event, 1)
193-
for i, message := range msg.Messages {
261+
// publishes — one event per message carrying the original message under
262+
// "rxtxMsg" alongside "type" and "clientIp". Each event's Private holds the
263+
// shared *batchAck so the ACK handler can correlate acks back to this batch.
264+
func buildEvents(beatName, clientIP string, msg *rtq.MessageBatch, ack *batchAck) []beat.Event {
265+
events := make([]beat.Event, 0, len(msg.Messages))
266+
for _, message := range msg.Messages {
194267
events = append(events, beat.Event{
195268
Timestamp: time.Now(),
196269
Fields: common.MapStr{
197270
"type": beatName,
198271
"rxtxMsg": message,
199272
"clientIp": clientIP,
200273
},
201-
Private: i,
274+
Private: ack,
202275
})
203276
}
204277
return events
205278
}
206279

207280
// inHandler builds the POST /in gin handler. Metric updates are injected as
208281
// callbacks (onBatch, onMessages) so the handler can be exercised in tests
209-
// without registering against the global prometheus registry. The handler
210-
// responds before publishing so a slow output never blocks the rxtx client.
211-
func inHandler(beatName string, logger *zap.Logger, pub eventPublisher, onBatch func(), onMessages func(n int)) gin.HandlerFunc {
282+
// without registering against the global prometheus registry.
283+
//
284+
// Durability: the handler publishes the batch and then waits for the output to
285+
// acknowledge delivery before responding 200, bounded by ackTimeout. If the
286+
// ack does not arrive in time it responds 504 so the sender (e.g. rxtx) keeps
287+
// its durable copy and retries, rather than dropping it on a premature 200.
288+
func inHandler(beatName string, logger *zap.Logger, pub eventPublisher, ackTimeout time.Duration, onBatch func(), onMessages func(n int)) gin.HandlerFunc {
212289
return func(c *gin.Context) {
213290
onBatch()
214291

@@ -224,14 +301,35 @@ func inHandler(beatName string, logger *zap.Logger, pub eventPublisher, onBatch
224301
return
225302
}
226303

227-
// respond quickly to avoid getting a re-send from the server
228-
c.JSON(http.StatusOK, gin.H{"status": "OK"})
304+
n := len(msg.Messages)
305+
onMessages(n)
306+
307+
// Nothing to deliver: acknowledge immediately.
308+
if n == 0 {
309+
c.JSON(http.StatusOK, gin.H{"status": "OK"})
310+
return
311+
}
229312

230-
onMessages(len(msg.Messages))
231313
// Capture the client IP synchronously: the gin context is recycled
232314
// once the handler returns, so it must not be read from the goroutine.
233-
events := buildEvents(beatName, c.ClientIP(), msg)
315+
ack := newBatchAck(n)
316+
events := buildEvents(beatName, c.ClientIP(), msg, ack)
234317

235318
go pub.PublishAll(events)
319+
320+
select {
321+
case <-ack.done:
322+
c.JSON(http.StatusOK, gin.H{"status": "OK"})
323+
case <-time.After(ackTimeout):
324+
logger.Warn("Run",
325+
zap.String("state", "ack timeout"),
326+
zap.Int("messages", n),
327+
zap.Duration("timeout", ackTimeout),
328+
)
329+
c.JSON(http.StatusGatewayTimeout, gin.H{
330+
"status": "TIMEOUT",
331+
"message": "events accepted but not acknowledged by the output within timeout",
332+
})
333+
}
236334
}
237335
}

0 commit comments

Comments
 (0)