ADR-012: Flush Ticker Ownership in the Writer Layer¶
Status: Accepted Date: 2026-03-04 Phase: 9 — Goroutine Scaffolding and fsync
Context¶
Phase 9 adds a periodic checkpoint-write to BinaryEvtxWriter to bound the data-loss window when the process is interrupted between graceful shutdown signals. The ticker must fire somewhere in the call stack. Candidate layers are:
- Queue layer (
pkg/queue/queue.go) — a worker goroutine that callswriter.Flush()on a ticker - Adapter layer (
pkg/evtx/BinaryEvtxWriter) — a goroutine owned by the adapter - go-evtx library layer (
github.com/fjacquet/go-evtx Writer) — a goroutine owned by the library'sWriterstruct
Decision¶
The flush ticker lives in the go-evtx library layer, inside the Writer struct.
Rationale¶
Writer interface stability¶
The evtx.Writer interface (defined in pkg/evtx/writer.go) has exactly two methods:
cee-exporter has five Writer backends: GELF, Win32 EventLog, Syslog, Beats, and BinaryEvtx. If the flush ticker lived in the queue layer, it would need to call writer.Flush(). This would require adding Flush() to the Writer interface — which would force GELF, Win32, Syslog, and Beats to implement a method they have no meaningful semantics for (network writers do not buffer; Win32 EventLog is synchronous).
Stub implementations (func (w *GELFWriter) Flush() error { return nil }) are technically correct but are a code smell: they mislead readers into thinking flushing is a universal operation, and they create surface area for future bugs when someone calls Flush() on the wrong backend expecting durability.
Concern isolation¶
Only BinaryEvtxWriter has a durability concern that benefits from periodic flushing. GELF and Syslog are network protocols where "flush" has no meaning at the application layer. Win32 EventLog is synchronous by design. Isolating the flush goroutine inside the go-evtx Writer keeps the durability concern co-located with the implementation that needs it.
Library encapsulation¶
go-evtx is a standalone library (github.com/fjacquet/go-evtx). Making the periodic flush a first-class feature of the library's Writer means any consumer of go-evtx gets the goroutine lifecycle without having to implement it. cee-exporter benefits from this immediately; future consumers (e.g., forensics tools) benefit automatically.
Adapter layer alternative rejected¶
Placing the goroutine in BinaryEvtxWriter (the cee-exporter adapter) would duplicate the lifecycle logic (done channel, WaitGroup) in cee-exporter rather than in the library where it belongs. It would also prevent go-evtx from advertising its own flush semantics in its public API.
Consequences¶
evtx.Writerinterface remains at two methods (WriteEvent,Close) — no breaking change to GELF, Win32, Syslog, or Beatsgo-evtxWriter.Close()is responsible for signaling the goroutine and performing the final flush- cee-exporter passes
FlushIntervalSecto go-evtx at construction time viagoevtx.RotationConfig - Phase 10 (open-handle incremental flush) will add
f.Sync()inside the goroutine once a persistent file handle is available - Phase 12 will expose
FlushIntervalSecvalidation (reject 0 with a startup error)
Alternatives Considered¶
| Alternative | Reason Rejected |
|---|---|
Queue layer ticker calling writer.Flush() |
Requires adding Flush() to Writer interface; forces stubs on all backends |
| Adapter-layer goroutine in BinaryEvtxWriter | Lifecycle code in wrong layer; go-evtx cannot advertise its own flush semantics |
| No goroutine (write-on-close only) | Unacceptable for long-running sessions; Phase 9 goal is bounded durability |