v2.0.7 — Typed global helpers, KV direct path, lock-free MPMC ring buffer
Polish + correctness release on top of v2.0.6. Highlights: typed-Field global helpers are now genuinely zero-alloc, the untyped KV path got a direct-text shortcut, several real correctness bugs fixed, and the AsyncWriter ring buffer was rewritten as a proper LMAX Disruptor MPMC.
Breaking change
Global helpers zlog.Debug / Info / Warn / Error / Fatal now take typed Field varargs:
func Info(msg string, fields ...Field)The previous signature accepted ...any and tried to handle both typed and untyped calls. With Field at 56 bytes (won't fit inline in interface storage), every call boxed each Field on the heap — three allocs/op even when the caller meant the typed path. Splitting the API removes that cost.
Migration: untyped key/value style moves to the explicit *KV helpers (zlog.InfoKV, DebugKV, etc.) — same signature as before, just renamed:
// Before:
zlog.Info("user logged in", "user_id", 12345)
// After:
zlog.InfoKV("user logged in", "user_id", 12345)The Go compiler catches every old call site as a type mismatch on the variadic, so migration is mechanical. Anyone already using typed Fields through the global helper gets a 3× speedup for free — same call site, zero allocations.
Performance
| Bench (Apple M5) | v2.0.6 | v2.0.7 |
|---|---|---|
zlog.Info("msg", String(..), Int(..)) global |
88 ns / 3 allocs | 34 ns / 0 / 0 |
zlog.InfoKV("msg", "k", v) global |
53 ns | 32 ns / 0 / 0 |
| StructuredLogger → TerminalWriter | 27 ns | 27 ns / 0 / 0 |
| LogfmtWriter binary decode | 76 ns | 65 ns / 0 / 0 |
| LogfmtWriter direct structured | 89 ns | 72 ns / 0 / 0 |
| AsyncWriter | 234 ns | 212 ns / 0 / 0 |
All paths zero-alloc. Direct-text KV path on *TerminalWriter and *LogfmtWriter skips the binary encode + decode round-trip — that's where the 40 % KV speedup comes from.
Correctness fixes
Bytes(nil)andBytes([]byte{})no longer panic.&val[0]was unconditional; now an empty slice produces an empty Bytes field.Logger.logclamps long messages to 65535 bytes (the uint16 header limit). Previously a 70 KB message produced a record where the headermsgLenwrapped viauint16(70000) = 4464but all 70 KB were copied — decoders would read the wrong length and treat the rest as garbage.Logger.FatalandStructuredLogger.Fatalalways exit, even when the level filters the message out. PreviouslySetLevel(>Fatal)could log nothing AND skip the exit.LogfmtWriter.Write(binary decode path) is now zero-alloc on long records. It pre-sizes the buffer with a one-pass scan over the field section, mirroring whatwriteStructuredalready did.LogfmtWritercaches the formatted timestamp by Unix second soAppendFormatruns once per second, not once per log.
AsyncWriter ring buffer rewritten
The previous lock-free ring buffer had a real correctness bug under -race: once a consumer's CAS-tail succeeded, the producer's full check immediately allowed a Put at head = oldTail + size, which targets the same slot the consumer is about to read. The consumer's post-read Store(nil) would clobber a freshly-stored item from the next generation, causing a nil-deref in workers. Wrapping the head/tail counters via mask additionally exposed the design to ABA.
v2.0.7 replaces the design with the LMAX Disruptor pattern (per-slot sequence numbers): each slot carries an atomic seq counter that doubles as a generation marker. Producers claim only when seq == head; consumers consume only when seq == tail+1. After a Get, the slot's seq is set to tail + size, explicitly marking it ready for the producer's next generation. No silent overwrite, no ABA, no spin-wait — fully non-blocking. Holds size items rather than size-1 since the seq counter distinguishes empty from full.
The corrected lock-free design ends up faster than the original broken version (212 ns/op vs 234 ns/op).
Other changes
- Long-record alloc test (
long_alloc_test.go) is now//go:build !race. Race detector triggers GC frequently enough to clearsync.Pool's localcache for 1 MiB-class buffers, leaking ~1 alloc/op into the measurement window. That's a property of-race, not the production hot path. Production code without-racekeeps the pool warm and runs at 0 allocs/op as advertised. - Inlined the field encoder into
formatStructuredMessage(saves a function call per field). StructuredLogger.logKVnow detects*TerminalWriterand*LogfmtWriterand dispatches to the direct-text path, skipping the binary encode + decode round-trip.- Comprehensive regression test coverage:
Bytes(empty),LogfmtWriter.Writelong-record alloc, plain Logger long-message header/payload match, KV direct-path output on terminal + logfmt, Fatal-with-filtered-level subprocess test. - Doc fixes:
root.go init()comment matches the code,Any()documents thatfmt.Sprintallocates,ultimate_zerons claim corrected to match measured M5 number.
Compatibility
- Go 1.23+ (unchanged).
- Public surface of
Logger,StructuredLogger,UltimateLogger, allFieldconstructors and writers behaves identically apart from theInfo/Debug/Warn/Error/Fatalglobal helpers, which now take...Field(KV style →*KVhelpers). - Binary log format unchanged from v2.0.6.