Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Fixed

* Fixed the reflection buffer used by `zap.Any`/`zap.Reflect` fields never being returned to the buffer pool by the developer-friendly `Encoder`, which made every such log line allocate a fresh 1 KiB buffer (measured 1451 B/op down to 392 B/op, 13 allocs down to 11).

### Removed

* Removed the unused `_jsonPool`/`_loggerPool` encoder pools. Nothing ever returned an encoder to them, so they only ever allocated; `jsonEncoder.clone` now allocates directly, which also removes a shared mutable state hazard. No API change.

## v1.2.2

### Added
Expand Down
9 changes: 0 additions & 9 deletions core_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -698,14 +697,6 @@ func ptrBoolToString(value *bool) string {
}
}

func ptrIntToString(value *int) string {
if value == nil {
return "<nil>"
}

return strconv.FormatInt(int64(*value), 10)
}

func ptrStringToString(value *string) string {
if value == nil {
return "<nil>"
Expand Down
59 changes: 26 additions & 33 deletions encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"math"
"path"
"strings"
"sync"
"time"
"unicode/utf8"

Expand All @@ -41,10 +40,6 @@ var bufferpool = buffer.NewPool()
var levelToColor map[zapcore.Level]Color
var levelToShort map[zapcore.Level]string

var _loggerPool = sync.Pool{New: func() interface{} {
return &Encoder{}
}}

func init() {
levelToColor = make(map[zapcore.Level]Color, 7)
levelToColor[zap.DebugLevel] = MagentaFg
Expand Down Expand Up @@ -243,7 +238,10 @@ func maybeRemovePackageVersion(input string) string {

func (c Encoder) writeJSONFields(line *buffer.Buffer, extra []zapcore.Field) {
context := c.Clone().(*Encoder)
defer context.buf.Free()
defer func() {
context.buf.Free()
context.free()
}()

addFields(context, extra)
context.closeOpenNamespaces()
Expand Down Expand Up @@ -277,27 +275,6 @@ func addFields(enc zapcore.ObjectEncoder, fields []zapcore.Field) {
// For JSON-escaping; see jsonEncoder.safeAddString below.
const _hex = "0123456789abcdef"

var _jsonPool = sync.Pool{New: func() interface{} {
return &jsonEncoder{}
}}

func getJSONEncoder() *jsonEncoder {
return _jsonPool.Get().(*jsonEncoder)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this means then that the whole pool is now useless?

Ill need to verify if still used, otherwise we maybe better removing it entierly.

}

func putJSONEncoder(enc *jsonEncoder) {
if enc.reflectBuf != nil {
enc.reflectBuf.Free()
}
enc.EncoderConfig = nil
enc.buf = nil
enc.spaced = false
enc.openNamespaces = 0
enc.reflectBuf = nil
enc.reflectEnc = nil
_jsonPool.Put(enc)
}

type jsonEncoder struct {
*zapcore.EncoderConfig
buf *buffer.Buffer
Expand Down Expand Up @@ -545,15 +522,35 @@ func (enc *jsonEncoder) Clone() zapcore.Encoder {
return clone
}

// clone allocates a fresh encoder instead of drawing one from a `sync.Pool` like
// upstream zap does. Pooling only pays off when every clone is handed back, and
// most of ours are not: `Encoder.Clone` (through `zapcore.Core.With`) keeps its
// clone alive for the lifetime of the derived logger. Benchmarked, the pool saved
// one 48 byte allocation per log line with no measurable latency change, while
// costing ~33% on the never-returned `Clone` path. Not worth the extra state.
func (enc *jsonEncoder) clone() *jsonEncoder {
clone := getJSONEncoder()
clone := &jsonEncoder{}
clone.EncoderConfig = enc.EncoderConfig
clone.spaced = enc.spaced
clone.openNamespaces = enc.openNamespaces
clone.buf = bufferpool.Get()
return clone
}

// free returns the encoder's lazily allocated reflection buffer to the buffer
// pool. It must be called when a short-lived encoder is discarded, otherwise the
// buffer behind `zap.Any`/`zap.Reflect` fields is never recycled.
//
// The encoder's main `buf` is deliberately left alone: ownership of it is passed
// to the caller in `EncodeEntry`, and callers that keep it free it themselves.
func (enc *jsonEncoder) free() {
if enc.reflectBuf != nil {
enc.reflectBuf.Free()
enc.reflectBuf = nil
enc.reflectEnc = nil
}
}

func (enc *jsonEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) {
final := enc.clone()
final.buf.AppendByte('{')
Expand Down Expand Up @@ -620,14 +617,10 @@ func (enc *jsonEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (
}

ret := final.buf
putJSONEncoder(final)
final.free()
return ret, nil
}

func (enc *jsonEncoder) truncate() {
enc.buf.Reset()
}

func (enc *jsonEncoder) closeOpenNamespaces() {
for i := 0; i < enc.openNamespaces; i++ {
enc.buf.AppendByte('}')
Expand Down
71 changes: 71 additions & 0 deletions encoder_concurrency_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package logging

import (
"strconv"
"sync"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)

// countingSyncer is a `zapcore.WriteSyncer` that only records how many lines it
// received, so the concurrency test can assert nothing was dropped without
// serializing the encoder behind a shared buffer's growth.
type countingSyncer struct {
mu sync.Mutex
lines int
bytes int
}

func (s *countingSyncer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()

s.lines++
s.bytes += len(p)

return len(p), nil
}

func (s *countingSyncer) Sync() error { return nil }

// TestEncoderConcurrentWithAndLog exercises `Encoder.Clone` and
// `Encoder.EncodeEntry` from several goroutines sharing one base logger, which is
// how `zapcore.Core.With` and log emission interleave in practice.
//
// It is a guard against shared mutable state creeping back into the encoder (a
// pooled or otherwise reused `jsonEncoder`), not a reproduction of a known race:
// it passes on the pooled implementation too, because that pool was never fed.
// Meaningful under `-race`.
func TestEncoderConcurrentWithAndLog(t *testing.T) {
const goroutines = 8
const iterations = 2000

syncer := &countingSyncer{}
core := zapcore.NewCore(NewEncoder(1, false), syncer, zap.DebugLevel)
base := zap.New(core).Named("base").With(zap.String("thread_id", "t-1"), zap.String("agent_id", "a-1"))

var wg sync.WaitGroup
for i := range goroutines {
wg.Add(1)
go func(n int) {
defer wg.Done()

for j := range iterations {
// `With` clones the encoder, the log call clones it again while encoding.
derived := base.With(zap.Int("n", n))

// `zap.Any` on a non-primitive goes through the encoder's lazily allocated
// reflection buffer, covering that path too.
derived.Info("hello", zap.String("j", strconv.Itoa(j)), zap.Any("payload", map[string]int{"j": j}))
}
}(i)
}
wg.Wait()

require.Equal(t, goroutines*iterations, syncer.lines, "every log entry should reach the syncer exactly once")
assert.Positive(t, syncer.bytes, "encoded entries should not be empty")
}
Loading