Skip to content

v2.0.7 — Typed global helpers, KV direct path, lock-free MPMC ring buffer

Choose a tag to compare

@semihalev semihalev released this 25 Apr 18:15
· 1 commit to main since this release

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) and Bytes([]byte{}) no longer panic. &val[0] was unconditional; now an empty slice produces an empty Bytes field.
  • Logger.log clamps long messages to 65535 bytes (the uint16 header limit). Previously a 70 KB message produced a record where the header msgLen wrapped via uint16(70000) = 4464 but all 70 KB were copied — decoders would read the wrong length and treat the rest as garbage.
  • Logger.Fatal and StructuredLogger.Fatal always exit, even when the level filters the message out. Previously SetLevel(>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 what writeStructured already did.
  • LogfmtWriter caches the formatted timestamp by Unix second so AppendFormat runs 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 clear sync.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 -race keeps the pool warm and runs at 0 allocs/op as advertised.
  • Inlined the field encoder into formatStructuredMessage (saves a function call per field).
  • StructuredLogger.logKV now detects *TerminalWriter and *LogfmtWriter and dispatches to the direct-text path, skipping the binary encode + decode round-trip.
  • Comprehensive regression test coverage: Bytes(empty), LogfmtWriter.Write long-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 that fmt.Sprint allocates, ultimate_zero ns claim corrected to match measured M5 number.

Compatibility

  • Go 1.23+ (unchanged).
  • Public surface of Logger, StructuredLogger, UltimateLogger, all Field constructors and writers behaves identically apart from the Info/Debug/Warn/Error/Fatal global helpers, which now take ...Field (KV style → *KV helpers).
  • Binary log format unchanged from v2.0.6.