From d8bee44f28fafe222266688f300f8ad79df95a84 Mon Sep 17 00:00:00 2001 From: Ajit Pratap Singh <18012955+ajitpratap0@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:40:15 +0530 Subject: [PATCH 1/4] perf(metrics): count pool Get/Put only while metrics are enabled [W1-07] Pool statistics are gated on a shared internal/metricsgate flag inside poolstats.Counter.Get/Put (still inlined at all call sites). GetPoolStats returns zeros for the three standard pools while disabled. Breaking: callers must metrics.Enable() before measuring. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- CHANGELOG.md | 7 +- internal/metricsgate/metricsgate.go | 32 +++++++ internal/metricsgate/metricsgate_test.go | 75 +++++++++++++++ internal/poolstats/poolstats.go | 34 +++++-- internal/poolstats/poolstats_test.go | 70 ++++++++++++++ pkg/gosqlx/pool_hygiene_test.go | 52 ++++++++++ pkg/metrics/doc.go | 6 ++ pkg/metrics/metrics.go | 31 +++--- pkg/metrics/metrics_test.go | 28 ++++++ pkg/metrics/pool_stats.go | 40 ++++++-- pkg/metrics/pool_stats_test.go | 117 +++++++++++++++++++++++ pkg/transform/pool_hygiene_test.go | 11 +++ pkg/transpiler/pool_hygiene_test.go | 11 +++ 13 files changed, 478 insertions(+), 36 deletions(-) create mode 100644 internal/metricsgate/metricsgate.go create mode 100644 internal/metricsgate/metricsgate_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a2662f2..fec48cb7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking Changes +- **Pool statistics [W1-07]**: Get/Put operations are counted only while metrics are enabled. While disabled, `metrics.GetPoolStats` returns zero values for the three standard pools and omits custom pools. Counts collected earlier remain available when metrics are enabled again. + - **Migration:** Call `metrics.Enable()` before the workload to be measured, for example at process start. Tests asserting pool balance through `GetPoolStats` deltas should enable metrics first and restore the previous state in cleanup. Counts are not retroactive. + ### Added - **Benchmark and perf-gate infrastructure (performance overhaul, wave 0)**: pull requests are now judged by interleaved A/B measurements instead of @@ -101,6 +105,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 benchmarks that publish diagnostics no longer time a growing buffer. ### Performance +- **Pools [W1-07]**: Skip atomic pool counter updates while metrics are disabled. - **gosqlx**: errors returned by `Parse`, `Validate`, `Lint` and the tree helpers now wrap the sentinel and the underlying error in an unexported `wrapErr` that builds its message lazily, instead of `fmt.Errorf("%w: %w", ...)` on every failure. Error text, `errors.Is` and `errors.As` behaviour are unchanged; only the unexported concrete error type differs. The saving applies to callers that only check `errors.Is`/`errors.As` or drop the message: when the message is built (`err.Error()`, as the LSP and CLI do) the gain is about 2 allocs/op and 3-4% B/op per error (syntax_mid 21 -> 19 allocs). Because the message is now built on demand, `Error()` reflects any later mutation of the inner `*errors.Error` reached through `errors.As`; previously the text was fixed at creation. [W1-03] - **Linter L010**: scans bytes in a single pass instead of rebuilding substrings and running a regular expression; `BenchmarkLintRule/L010` is 67.5% faster on the @@ -120,7 +125,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Tokenizer**: token Start/End locations are now computed from a cached current-line window (a range check and a subtraction) instead of two calls to the non-inlinable position lookup per token; out-of-window offsets take the existing lookup. Token positions are identical, including after a UTF-8 BOM and across pooled-tokenizer reuse (guarded by an oracle test). [W2-02] - **Tokenizer**: `@param` and SQL Server `@@var` token values are now slices of the input instead of `"@"+name` concatenations, removing one string allocation per parameter (SQL Server real-world allocs/op -90% to -100%, MariaDB/MySQL/Snowflake -50% to -67% on the large cells). Names that `readIdentifier` treats specially (`U&` escapes, a single letter before a quote, compound-keyword starts such as `@group by`, non-ASCII names) keep the previous path, so token types, values and spans are identical (pinned by a golden over 9 dialects). [W2-03] - **Tokenizer**: tokens are now built in place. The scanner and its token readers write each token's fields directly into its slot of the output slice instead of returning a 40-byte `models.Token` by value that was then copied into a 72-byte `models.TokenWithSpan` and appended. On amd64 those copies reloaded just-spilled structs with 16-byte loads that cannot be store-forwarded. Token output, spans and errors are identical (token goldens unchanged); no API or behaviour change. [W2-10] -- **Pools**: the tokenizer, parser and AST pools now count Get/Put through precreated, cache-line padded atomic counters (`internal/poolstats`) instead of an RWMutex-guarded map keyed by pool name, removing a lock, map lookup and string hash from every pool operation. `metrics.GetPoolStats` and `metrics.ResetPoolStats` behave exactly as before; named custom pools keep the map path. [W1-01] +- **Pools**: the tokenizer, parser and AST pools now count Get/Put through precreated, cache-line padded atomic counters (`internal/poolstats`) instead of an RWMutex-guarded map keyed by pool name, removing a lock, map lookup and string hash from every pool operation. `metrics.GetPoolStats` and `metrics.ResetPoolStats` retain their previous behaviour until W1-07 (see Breaking Changes); named custom pools keep the map path. [W1-01] - **Parser**: token preprocessing now takes a type-gated fast path. Only identifier, keyword, aggregate and compound tokens take the normalisation path; every other token is appended with a single copy. Output is identical (guarded by an old-versus-new differential test); no API or behaviour change. - **Parser**: the parser's current token is now a pointer into its token slice (with a per-parser zero EOF sentinel) instead of a 72-byte copy made on every `advance`, and `peekToken` returns a pointer, which removes most GC write-barrier work and struct copying from the parse loop. Parse results, errors and positions are byte-identical (behaviour goldens plus a recovery-path differential); no API change. A zero-value `&Parser{}` keeps working. [W3-02] - **Fingerprint, formatter, transform**: `fingerprint.Normalize`/`Fingerprint`, `Formatter.Format` and `transform.ParseSQL`/`ParseSQLWithDialect` tokenize through the internal pooled token scratch instead of the public `TokenizeString`. `Normalize` allocates 42-70% fewer bytes/op and `transform.ParseSQL` 49-78% fewer, with 2-3 fewer allocs/op and no output change. [W5-01] diff --git a/internal/metricsgate/metricsgate.go b/internal/metricsgate/metricsgate.go new file mode 100644 index 000000000..28ee2d49a --- /dev/null +++ b/internal/metricsgate/metricsgate.go @@ -0,0 +1,32 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package metricsgate holds the shared flag controlling metrics collection. +package metricsgate + +import "sync/atomic" + +const cacheLine = 64 + +var gate struct { + _ [cacheLine]byte + on atomic.Bool + _ [cacheLine]byte +} + +// Enabled reports whether metrics collection is enabled. +func Enabled() bool { return gate.on.Load() } + +// Set enables or disables metrics collection. +func Set(on bool) { gate.on.Store(on) } diff --git a/internal/metricsgate/metricsgate_test.go b/internal/metricsgate/metricsgate_test.go new file mode 100644 index 000000000..5b00714ea --- /dev/null +++ b/internal/metricsgate/metricsgate_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package metricsgate + +import ( + "sync" + "testing" + "unsafe" +) + +func TestDefaultAndToggle(t *testing.T) { + if Enabled() { + t.Fatal("gate must default to disabled in a fresh test binary") + } + t.Cleanup(func() { Set(false) }) + Set(true) + if !Enabled() { + t.Fatal("Set(true) did not enable gate") + } + Set(false) + if Enabled() { + t.Fatal("Set(false) did not disable gate") + } +} + +func TestGatePadding(t *testing.T) { + size := unsafe.Sizeof(gate) + offset := unsafe.Offsetof(gate.on) + if size < 64+4+64 { + t.Errorf("gate size = %d, want at least 132", size) + } + if offset < 64 { + t.Errorf("flag offset = %d, want at least 64", offset) + } + if trailing := size - offset - unsafe.Sizeof(gate.on); trailing < 64 { + t.Errorf("trailing padding = %d, want at least 64", trailing) + } +} + +func TestGateConcurrentToggle(t *testing.T) { + was := Enabled() + t.Cleanup(func() { Set(was) }) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 5000; j++ { + _ = Enabled() + } + }() + } + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 5000; j++ { + Set(j%2 == 0) + } + }() + } + wg.Wait() +} diff --git a/internal/poolstats/poolstats.go b/internal/poolstats/poolstats.go index 913595b27..ac143a7d1 100644 --- a/internal/poolstats/poolstats.go +++ b/internal/poolstats/poolstats.go @@ -13,13 +13,17 @@ // limitations under the License. // Package poolstats holds the precreated, cache-line padded counters for the -// three standard pools (tokenizer, parser, ast). The typed API is a single -// atomic add and inlines at every call site, with no lock, map lookup or -// string hash. pkg/metrics exposes the counters through GetPoolStats and -// ResetPoolStats. +// three standard pools (tokenizer, parser, ast). Counters count only while +// metrics are enabled. The typed API inlines at every call site, with no lock, +// map lookup or string hash. pkg/metrics exposes the counters through +// GetPoolStats and ResetPoolStats. package poolstats -import "sync/atomic" +import ( + "sync/atomic" + + "github.com/ajitpratap0/GoSQLX/internal/metricsgate" +) // Counter is a Get/Put counter pair padded to its own 64-byte cache line so // the three standard pools never false-share. @@ -29,11 +33,23 @@ type Counter struct { _ [48]byte } -// Get records one Get() and returns the new total. -func (c *Counter) Get() int64 { return c.gets.Add(1) } +// Get records one Get() only while metrics are enabled and returns the new total. +// It returns 0 without recording anything when metrics are disabled. +func (c *Counter) Get() int64 { + if !metricsgate.Enabled() { + return 0 + } + return c.gets.Add(1) +} -// Put records one Put() and returns the new total. -func (c *Counter) Put() int64 { return c.puts.Add(1) } +// Put records one Put() only while metrics are enabled and returns the new total. +// It returns 0 without recording anything when metrics are disabled. +func (c *Counter) Put() int64 { + if !metricsgate.Enabled() { + return 0 + } + return c.puts.Add(1) +} // Load returns the current gets and puts. func (c *Counter) Load() (gets, puts int64) { return c.gets.Load(), c.puts.Load() } diff --git a/internal/poolstats/poolstats_test.go b/internal/poolstats/poolstats_test.go index 1dd532b7a..06b9bed41 100644 --- a/internal/poolstats/poolstats_test.go +++ b/internal/poolstats/poolstats_test.go @@ -18,6 +18,8 @@ import ( "sync" "testing" "unsafe" + + "github.com/ajitpratap0/GoSQLX/internal/metricsgate" ) func TestCounterPaddedToCacheLine(t *testing.T) { @@ -27,6 +29,7 @@ func TestCounterPaddedToCacheLine(t *testing.T) { } func TestCounterGetPutReset(t *testing.T) { + enablePoolStats(t) var c Counter if n := c.Get(); n != 1 { t.Fatalf("first Get = %d, want 1", n) @@ -57,6 +60,7 @@ func TestByName(t *testing.T) { } func TestCounterConcurrent(t *testing.T) { + enablePoolStats(t) var c Counter const goroutines, per = 8, 5000 var wg sync.WaitGroup @@ -75,3 +79,69 @@ func TestCounterConcurrent(t *testing.T) { t.Fatalf("Load = %d/%d, want %d each", g, p, goroutines*per) } } + +func enablePoolStats(t testing.TB) { + was := metricsgate.Enabled() + metricsgate.Set(true) + t.Cleanup(func() { metricsgate.Set(was) }) +} + +func TestCounterDisabledIsNoop(t *testing.T) { + enablePoolStats(t) + metricsgate.Set(false) + var c Counter + if got := c.Get(); got != 0 { + t.Fatalf("disabled Get = %d, want 0", got) + } + if got := c.Put(); got != 0 { + t.Fatalf("disabled Put = %d, want 0", got) + } + if g, p := c.Load(); g != 0 || p != 0 { + t.Fatalf("disabled Load = %d/%d, want 0/0", g, p) + } + metricsgate.Set(true) + if got := c.Get(); got != 1 { + t.Fatalf("enabled Get = %d, want 1", got) + } + if got := c.Put(); got != 1 { + t.Fatalf("enabled Put = %d, want 1", got) + } + metricsgate.Set(false) + if got := c.Get(); got != 0 { + t.Fatalf("disabled Get after toggle = %d, want 0", got) + } + if got := c.Put(); got != 0 { + t.Fatalf("disabled Put after toggle = %d, want 0", got) + } + if g, p := c.Load(); g != 1 || p != 1 { + t.Fatalf("retained Load = %d/%d, want 1/1", g, p) + } +} + +func TestCounterToggleRace(t *testing.T) { + enablePoolStats(t) + var c Counter + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 5000; j++ { + c.Get() + c.Put() + } + }() + } + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 5000; j++ { + metricsgate.Set(j%2 == 0) + } + }() + wg.Wait() + metricsgate.Set(true) + if g, p := c.Load(); g < 0 || p < 0 { + t.Fatalf("negative counts: %d/%d", g, p) + } +} diff --git a/pkg/gosqlx/pool_hygiene_test.go b/pkg/gosqlx/pool_hygiene_test.go index f93a3c047..1965f1eac 100644 --- a/pkg/gosqlx/pool_hygiene_test.go +++ b/pkg/gosqlx/pool_hygiene_test.go @@ -25,11 +25,22 @@ import ( // These tests read process-global pool counters, so they use before/after // deltas, never call metrics.ResetPoolStats and never run in parallel. +func enablePoolStats(t testing.TB) { + was := metrics.IsEnabled() + metrics.Enable() + t.Cleanup(func() { + if !was { + metrics.Disable() + } + }) +} + func poolDelta(before, after map[string]metrics.PoolStat, name string) (gets, puts int64) { return after[name].Gets - before[name].Gets, after[name].Puts - before[name].Puts } func TestPoolHygiene_Balanced(t *testing.T) { + enablePoolStats(t) const n = 50 tests := []struct { name string @@ -78,6 +89,7 @@ func TestPoolHygiene_Balanced(t *testing.T) { } func TestParseMultiple_SuccessCallerOwns(t *testing.T) { + enablePoolStats(t) before := metrics.GetPoolStats() results, err := ParseMultiple([]string{"SELECT 1", "SELECT 2", "SELECT 3"}) if err != nil { @@ -195,3 +207,43 @@ func TestAnalyze_ResultIndependentOfPool(t *testing.T) { t.Errorf("Score = %d, want 90", res.Score) } } + +func TestPoolStats_RealPoolsGatedEndToEnd(t *testing.T) { + enablePoolStats(t) + const n = 20 + run := func() { + t.Helper() + for i := 0; i < n; i++ { + tree, err := Parse("SELECT a FROM t WHERE b = 1") + if err != nil { + t.Fatalf("Parse: %v", err) + } + ast.ReleaseAST(tree) + } + } + + metrics.Disable() + run() + metrics.Enable() + s0 := metrics.GetPoolStats() + metrics.Disable() + run() + metrics.Enable() + s1 := metrics.GetPoolStats() + for _, pool := range []string{"tokenizer", "parser", "ast"} { + if s1[pool] != s0[pool] { + t.Errorf("%s counted while disabled: before=%+v after=%+v", pool, s0[pool], s1[pool]) + } + } + + run() + s2 := metrics.GetPoolStats() + // On main, each Parse plus ReleaseAST makes exactly one Get and one Put + // for each of these three pools. + for _, pool := range []string{"tokenizer", "parser", "ast"} { + gets, puts := poolDelta(s1, s2, pool) + if gets != n || puts != n { + t.Errorf("%s enabled delta: gets=%d puts=%d, want %d each", pool, gets, puts, n) + } + } +} diff --git a/pkg/metrics/doc.go b/pkg/metrics/doc.go index 139e8efad..702d09c91 100644 --- a/pkg/metrics/doc.go +++ b/pkg/metrics/doc.go @@ -92,6 +92,12 @@ // statementCount := len(ast.Statements) // metrics.RecordParse(duration, statementCount, err) // +// # Pool statistics +// +// Pool Get/Put statistics count only while metrics are enabled. Call Enable before +// the workload to measure; counts are not retroactive. GetPoolStats returns only +// the three standard pools with zero values while metrics are disabled. +// // # Object Pool Metrics // // Track pool efficiency for all pool types: diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index afc2a2e3f..1e64ac9a7 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -223,6 +223,7 @@ import ( "sync/atomic" "time" + "github.com/ajitpratap0/GoSQLX/internal/metricsgate" sqlerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" ) @@ -408,13 +409,11 @@ type Metrics struct { errorsMutex sync.RWMutex // Configuration - use atomic for thread safety - enabled int32 // 0 = disabled, 1 = enabled (atomic) startTime atomic.Value // time.Time stored atomically } // Global metrics instance var globalMetrics = &Metrics{ - enabled: 0, // 0 = disabled errorCounters: newErrorCodeCounters(), minQuerySize: -1, // -1 means not set yet } @@ -435,7 +434,7 @@ func init() { // defer metrics.Disable() // // All operations are now tracked func Enable() { - atomic.StoreInt32(&globalMetrics.enabled, 1) + metricsgate.Set(true) globalMetrics.startTime.Store(time.Now()) } @@ -451,7 +450,7 @@ func Enable() { // // Metrics collection stopped but data preserved // stats := metrics.GetStats() // Still returns last collected stats func Disable() { - atomic.StoreInt32(&globalMetrics.enabled, 0) + metricsgate.Set(false) } // IsEnabled returns whether metrics collection is currently active. @@ -463,7 +462,7 @@ func Disable() { // fmt.Println("Metrics are being collected") // } func IsEnabled() bool { - return atomic.LoadInt32(&globalMetrics.enabled) == 1 + return metricsgate.Enabled() } // RecordTokenization records a tokenization operation with duration, query size, and error. @@ -483,7 +482,7 @@ func IsEnabled() bool { // duration := time.Since(start) // metrics.RecordTokenization(duration, len(sqlBytes), err) func RecordTokenization(duration time.Duration, querySize int, err error) { - if atomic.LoadInt32(&globalMetrics.enabled) == 0 { + if !metricsgate.Enabled() { return } @@ -532,7 +531,7 @@ func RecordTokenization(duration time.Duration, querySize int, err error) { // metrics.RecordPoolPut() // }() func RecordPoolGet(fromPool bool) { - if atomic.LoadInt32(&globalMetrics.enabled) == 0 { + if !metricsgate.Enabled() { return } @@ -554,7 +553,7 @@ func RecordPoolGet(fromPool bool) { // metrics.RecordPoolPut() // }() func RecordPoolPut() { - if atomic.LoadInt32(&globalMetrics.enabled) == 0 { + if !metricsgate.Enabled() { return } @@ -579,7 +578,7 @@ func RecordPoolPut() { // statementCount := len(ast.Statements) // metrics.RecordParse(duration, statementCount, err) func RecordParse(duration time.Duration, statementCount int, err error) { - if atomic.LoadInt32(&globalMetrics.enabled) == 0 { + if !metricsgate.Enabled() { return } @@ -601,7 +600,7 @@ func RecordParse(duration time.Duration, statementCount int, err error) { // This function is a no-op if metrics are disabled. // Use this to track AST pool efficiency. func RecordASTPoolGet() { - if atomic.LoadInt32(&globalMetrics.enabled) == 0 { + if !metricsgate.Enabled() { return } atomic.AddInt64(&globalMetrics.astPoolGets, 1) @@ -611,7 +610,7 @@ func RecordASTPoolGet() { // This function is a no-op if metrics are disabled. // Use this to track AST pool efficiency. func RecordASTPoolPut() { - if atomic.LoadInt32(&globalMetrics.enabled) == 0 { + if !metricsgate.Enabled() { return } atomic.AddInt64(&globalMetrics.astPoolPuts, 1) @@ -621,7 +620,7 @@ func RecordASTPoolPut() { // This function is a no-op if metrics are disabled. // Use this to track statement pool efficiency. func RecordStatementPoolGet() { - if atomic.LoadInt32(&globalMetrics.enabled) == 0 { + if !metricsgate.Enabled() { return } atomic.AddInt64(&globalMetrics.stmtPoolGets, 1) @@ -631,7 +630,7 @@ func RecordStatementPoolGet() { // This function is a no-op if metrics are disabled. // Use this to track statement pool efficiency. func RecordStatementPoolPut() { - if atomic.LoadInt32(&globalMetrics.enabled) == 0 { + if !metricsgate.Enabled() { return } atomic.AddInt64(&globalMetrics.stmtPoolPuts, 1) @@ -641,7 +640,7 @@ func RecordStatementPoolPut() { // This function is a no-op if metrics are disabled. // Use this to track expression pool efficiency. func RecordExpressionPoolGet() { - if atomic.LoadInt32(&globalMetrics.enabled) == 0 { + if !metricsgate.Enabled() { return } atomic.AddInt64(&globalMetrics.exprPoolGets, 1) @@ -651,7 +650,7 @@ func RecordExpressionPoolGet() { // This function is a no-op if metrics are disabled. // Use this to track expression pool efficiency. func RecordExpressionPoolPut() { - if atomic.LoadInt32(&globalMetrics.enabled) == 0 { + if !metricsgate.Enabled() { return } atomic.AddInt64(&globalMetrics.exprPoolPuts, 1) @@ -752,7 +751,7 @@ type Stats struct { // jsonData, _ := json.MarshalIndent(stats, "", " ") // fmt.Println(string(jsonData)) func GetStats() Stats { - if atomic.LoadInt32(&globalMetrics.enabled) == 0 { + if !metricsgate.Enabled() { return Stats{} } diff --git a/pkg/metrics/metrics_test.go b/pkg/metrics/metrics_test.go index 34cf9396c..ea54e0ca4 100644 --- a/pkg/metrics/metrics_test.go +++ b/pkg/metrics/metrics_test.go @@ -20,6 +20,7 @@ import ( "testing" "time" + "github.com/ajitpratap0/GoSQLX/internal/metricsgate" sqlerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" "github.com/ajitpratap0/GoSQLX/pkg/models" ) @@ -498,3 +499,30 @@ func TestReset_ZerosErrorBuckets(t *testing.T) { t.Errorf("after Reset+record expected count 1, got %d", got) } } + +func TestIsEnabledMatchesGate(t *testing.T) { + was := IsEnabled() + t.Cleanup(func() { + if was { + Enable() + } else { + Disable() + } + }) + Enable() + if !IsEnabled() || !metricsgate.Enabled() { + t.Fatal("Enable must enable metrics and the shared gate") + } + Reset() + if !IsEnabled() || !metricsgate.Enabled() { + t.Fatal("Reset must preserve enabled state") + } + Disable() + if IsEnabled() || metricsgate.Enabled() { + t.Fatal("Disable must disable metrics and the shared gate") + } + Reset() + if IsEnabled() || metricsgate.Enabled() { + t.Fatal("Reset must preserve disabled state") + } +} diff --git a/pkg/metrics/pool_stats.go b/pkg/metrics/pool_stats.go index 8bc763dec..016b7d739 100644 --- a/pkg/metrics/pool_stats.go +++ b/pkg/metrics/pool_stats.go @@ -18,19 +18,22 @@ import ( "sync" "sync/atomic" + "github.com/ajitpratap0/GoSQLX/internal/metricsgate" "github.com/ajitpratap0/GoSQLX/internal/poolstats" ) // PoolStat holds utilization counters for a single named object pool. -// Gets counts total Get() calls (items retrieved), Puts counts total Put() calls -// (items returned). Active() returns an estimate of currently borrowed items. +// Gets counts Get() calls (items retrieved) and Puts counts Put() calls (items +// returned) made only while metrics are enabled. Active() estimates currently +// borrowed items and can be skewed if metrics are toggled while items are borrowed. type PoolStat struct { - Gets int64 `json:"gets"` // total Get() calls - Puts int64 `json:"puts"` // total Put() calls + Gets int64 `json:"gets"` // Get() calls while metrics are enabled + Puts int64 `json:"puts"` // Put() calls while metrics are enabled } // Active returns an estimate of currently borrowed items (Gets - Puts). -// May be negative during warm-up before items are returned to the pool. +// Toggling metrics while items are borrowed can skew the estimate. It can be +// negative if a Get occurred while disabled and its Put occurred after Enable. func (s PoolStat) Active() int64 { return s.Gets - s.Puts } @@ -66,10 +69,14 @@ func getOrCreatePoolCounter(name string) *poolCounter { return c } -// RecordNamedPoolGet records a Get() call for the named pool and returns the new total. +// RecordNamedPoolGet records a Get() call for the named pool only while metrics +// are enabled and returns the new total, or 0 when disabled (nothing is recorded). // Call this immediately after retrieving an object from a sync.Pool. // This is safe for concurrent use and uses atomic operations. func RecordNamedPoolGet(name string) int64 { + if !metricsgate.Enabled() { + return 0 + } if c := poolstats.ByName(name); c != nil { return c.Get() } @@ -77,10 +84,14 @@ func RecordNamedPoolGet(name string) int64 { return atomic.AddInt64(&c.gets, 1) } -// RecordNamedPoolPut records a Put() call for the named pool and returns the new total. +// RecordNamedPoolPut records a Put() call for the named pool only while metrics +// are enabled and returns the new total, or 0 when disabled (nothing is recorded). // Call this immediately before or after returning an object to a sync.Pool. // This is safe for concurrent use and uses atomic operations. func RecordNamedPoolPut(name string) int64 { + if !metricsgate.Enabled() { + return 0 + } if c := poolstats.ByName(name); c != nil { return c.Put() } @@ -88,11 +99,20 @@ func RecordNamedPoolPut(name string) int64 { return atomic.AddInt64(&c.puts, 1) } -// GetPoolStats returns a snapshot of all named pool counters. -// The map is keyed by pool name. Standard pool names ("tokenizer", "parser", "ast") -// are always present with zero values even if no operations have been recorded. +// GetPoolStats returns a snapshot of named pool counters counting only operations +// made while metrics are enabled. Standard pool names ("tokenizer", "parser", "ast") +// are always present. While disabled, it returns a new map containing exactly those +// names with zero values. Counters are retained and become visible after Enable; +// disabled operations are not retroactively counted. ResetPoolStats clears them. // This function is safe for concurrent use. func GetPoolStats() map[string]PoolStat { + if !metricsgate.Enabled() { + return map[string]PoolStat{ + "tokenizer": {}, + "parser": {}, + "ast": {}, + } + } poolStatsMu.RLock() defer poolStatsMu.RUnlock() result := make(map[string]PoolStat, len(poolCounters)+len(standardPoolNames)) diff --git a/pkg/metrics/pool_stats_test.go b/pkg/metrics/pool_stats_test.go index 61e6218fe..949b0890e 100644 --- a/pkg/metrics/pool_stats_test.go +++ b/pkg/metrics/pool_stats_test.go @@ -17,6 +17,8 @@ package metrics_test import ( "testing" + "github.com/ajitpratap0/GoSQLX/internal/metricsgate" + "github.com/ajitpratap0/GoSQLX/internal/poolstats" "github.com/ajitpratap0/GoSQLX/pkg/metrics" ) @@ -50,6 +52,7 @@ func TestPoolStats_HasASTPool(t *testing.T) { } func TestPoolStats_RecordGet(t *testing.T) { + enablePoolStats(t) metrics.ResetPoolStats() _ = metrics.RecordNamedPoolGet("tokenizer") @@ -69,6 +72,7 @@ func TestPoolStats_RecordGet(t *testing.T) { } func TestPoolStats_ActiveCalculation(t *testing.T) { + enablePoolStats(t) metrics.ResetPoolStats() metrics.RecordNamedPoolGet("parser") @@ -89,6 +93,8 @@ func TestPoolStats_ActiveCalculation(t *testing.T) { } func TestPoolStats_ResetClearsCounters(t *testing.T) { + enablePoolStats(t) + metrics.ResetPoolStats() metrics.RecordNamedPoolGet("ast") metrics.ResetPoolStats() @@ -101,6 +107,7 @@ func TestPoolStats_ResetClearsCounters(t *testing.T) { } func TestPoolStats_ConcurrentSafe(t *testing.T) { + enablePoolStats(t) metrics.ResetPoolStats() done := make(chan struct{}) @@ -129,6 +136,7 @@ func TestPoolStats_ConcurrentSafe(t *testing.T) { // the typed internal counters feed the same standard-pool statistics, that a // custom pool keeps the map path, and that ResetPoolStats zeroes both. func TestPoolStats_TypedCountersEquivalent(t *testing.T) { + enablePoolStats(t) metrics.ResetPoolStats() t.Cleanup(metrics.ResetPoolStats) @@ -167,3 +175,112 @@ func TestPoolStats_TypedCountersEquivalent(t *testing.T) { } } } + +func enablePoolStats(t testing.TB) { + was := metrics.IsEnabled() + metrics.Enable() + t.Cleanup(func() { + if !was { + metrics.Disable() + } + }) +} + +func TestPoolStats_DisabledReturnsZeros(t *testing.T) { + enablePoolStats(t) + metrics.ResetPoolStats() + t.Cleanup(metrics.ResetPoolStats) + metrics.RecordNamedPoolGet("tokenizer") + metrics.RecordNamedPoolGet("tokenizer") + metrics.RecordNamedPoolGet("custom") + poolstats.AST.Get() + metrics.Disable() + stats := metrics.GetPoolStats() + if len(stats) != 3 { + t.Fatalf("disabled stats = %v, want exactly three standard pools", stats) + } + for _, name := range []string{"tokenizer", "parser", "ast"} { + if got, ok := stats[name]; !ok || got != (metrics.PoolStat{}) { + t.Errorf("disabled %s = %+v (present %v), want zero", name, got, ok) + } + } + if _, ok := stats["custom"]; ok { + t.Error("custom pool visible while disabled") + } + // Each disabled read returns a fresh map, with no shared caller mutations. + stats["tokenizer"] = metrics.PoolStat{Gets: 99} + delete(stats, "parser") + if fresh := metrics.GetPoolStats(); len(fresh) != 3 || fresh["tokenizer"] != (metrics.PoolStat{}) { + t.Fatalf("disabled read reused a mutated map: %v", fresh) + } + metrics.Enable() + stats = metrics.GetPoolStats() + want := map[string]metrics.PoolStat{"tokenizer": {Gets: 2}, "parser": {}, "ast": {Gets: 1}, "custom": {Gets: 1}} + if len(stats) != len(want) { + t.Fatalf("restored stats = %v, want %v", stats, want) + } + for name, expected := range want { + if got, ok := stats[name]; !ok || got != expected { + t.Errorf("restored %s = %+v (present %v), want %+v", name, got, ok, expected) + } + } +} + +func TestPoolStats_DisabledDoesNotCount(t *testing.T) { + enablePoolStats(t) + metrics.ResetPoolStats() + t.Cleanup(metrics.ResetPoolStats) + metrics.Disable() + if got := metrics.RecordNamedPoolGet("tokenizer"); got != 0 { + t.Errorf("disabled tokenizer Get = %d", got) + } + if got := metrics.RecordNamedPoolPut("parser"); got != 0 { + t.Errorf("disabled parser Put = %d", got) + } + if got := metrics.RecordNamedPoolGet("custom-x"); got != 0 { + t.Errorf("disabled custom Get = %d", got) + } + if got := metrics.RecordNamedPoolPut("custom-put-x"); got != 0 { + t.Errorf("disabled custom Put = %d", got) + } + poolstats.Parser.Get() + metrics.Enable() + stats := metrics.GetPoolStats() + if len(stats) != 3 { + t.Fatalf("got pools %v, want exactly three standard pools", stats) + } + for _, name := range []string{"tokenizer", "parser", "ast"} { + if got, ok := stats[name]; !ok || got != (metrics.PoolStat{}) { + t.Errorf("%s = %+v (present %v), want zero", name, got, ok) + } + } + if _, ok := stats["custom-x"]; ok { + t.Error("disabled Get created a custom pool") + } + if _, ok := stats["custom-put-x"]; ok { + t.Error("disabled Put created a custom pool") + } +} + +func TestPoolStats_FollowsEnableDisable(t *testing.T) { + enablePoolStats(t) + metrics.ResetPoolStats() + t.Cleanup(metrics.ResetPoolStats) + if !metricsgate.Enabled() { + t.Fatal("Enable did not enable the shared gate") + } + if got := poolstats.Tokenizer.Get(); got != 1 { + t.Fatalf("enabled Get = %d, want 1", got) + } + metrics.Disable() + if metricsgate.Enabled() { + t.Fatal("Disable did not disable the shared gate") + } + if got := poolstats.Tokenizer.Get(); got != 0 { + t.Fatalf("disabled Get = %d, want 0", got) + } + if g, p := poolstats.Tokenizer.Load(); g != 1 || p != 0 { + t.Fatalf("retained counts = %d/%d, want 1/0", g, p) + } + metrics.Enable() +} diff --git a/pkg/transform/pool_hygiene_test.go b/pkg/transform/pool_hygiene_test.go index efe6f116e..074e20ec0 100644 --- a/pkg/transform/pool_hygiene_test.go +++ b/pkg/transform/pool_hygiene_test.go @@ -27,11 +27,22 @@ import ( // Pool counters are process-global: use deltas, never reset, never t.Parallel. +func enablePoolStats(t testing.TB) { + was := metrics.IsEnabled() + metrics.Enable() + t.Cleanup(func() { + if !was { + metrics.Disable() + } + }) +} + func poolDelta(before, after map[string]metrics.PoolStat, name string) (gets, puts int64) { return after[name].Gets - before[name].Gets, after[name].Puts - before[name].Puts } func TestParseHelpers_ParserPoolBalanced(t *testing.T) { + enablePoolStats(t) const n = 50 selStmt := mustParse(t, "SELECT id FROM t") updStmt := mustParse(t, "UPDATE t SET a = 1") diff --git a/pkg/transpiler/pool_hygiene_test.go b/pkg/transpiler/pool_hygiene_test.go index 2b4b6d2ac..606abfb15 100644 --- a/pkg/transpiler/pool_hygiene_test.go +++ b/pkg/transpiler/pool_hygiene_test.go @@ -28,7 +28,18 @@ import ( // Pool counters are process-global: use deltas, never reset, never t.Parallel. +func enablePoolStats(t testing.TB) { + was := metrics.IsEnabled() + metrics.Enable() + t.Cleanup(func() { + if !was { + metrics.Disable() + } + }) +} + func TestTranspile_ASTPoolBalanced(t *testing.T) { + enablePoolStats(t) const n = 50 tests := []struct { name string From 3843b87b2719e5255ca3ebad3317b49c69be7083 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Nov 2026 23:46:25 +0530 Subject: [PATCH 2/4] fix(metrics): address review round 1 on pool stats gating [W1-07] Fill gate numbers in CHANGELOG, store startTime before opening the gate, note gosqlx stats behaviour, tidy test imports. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- CHANGELOG.md | 4 ++-- pkg/metrics/metrics.go | 2 +- pkg/metrics/pool_stats_test.go | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fec48cb7d..465314d96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Breaking Changes -- **Pool statistics [W1-07]**: Get/Put operations are counted only while metrics are enabled. While disabled, `metrics.GetPoolStats` returns zero values for the three standard pools and omits custom pools. Counts collected earlier remain available when metrics are enabled again. +- **Pool statistics [W1-07]**: Get/Put operations are counted only while metrics are enabled. While disabled, `metrics.GetPoolStats` returns zero values for the three standard pools and omits custom pools. Counts collected earlier remain available when metrics are enabled again. The `gosqlx stats` command never enables metrics, so a fresh process prints zeros (as it effectively did before); call `metrics.Enable()` before the work you want counted. - **Migration:** Call `metrics.Enable()` before the workload to be measured, for example at process start. Tests asserting pool balance through `GetPoolStats` deltas should enable metrics first and restore the previous state in cleanup. Counts are not retroactive. ### Added @@ -105,7 +105,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 benchmarks that publish diagnostics no longer time a growing buffer. ### Performance -- **Pools [W1-07]**: Skip atomic pool counter updates while metrics are disabled. +- **Pools [W1-07]**: Skip atomic pool counter updates while metrics are disabled. amd64 layout-randomized perf gate: `PoolGetPut/serial` -5.41% (Hodges-Lehmann, p<0.001, A/A' +0.30%, spread 0.20%; benchstat 56.89 -> 54.03 ns/op), not in the low-power list; no gated E2E change (`ParseE2E/simple_select` -0.32%, p=0.543); allocs/op and B/op unchanged. The gain is mainly in contended/parallel use, which the gate does not measure. - **gosqlx**: errors returned by `Parse`, `Validate`, `Lint` and the tree helpers now wrap the sentinel and the underlying error in an unexported `wrapErr` that builds its message lazily, instead of `fmt.Errorf("%w: %w", ...)` on every failure. Error text, `errors.Is` and `errors.As` behaviour are unchanged; only the unexported concrete error type differs. The saving applies to callers that only check `errors.Is`/`errors.As` or drop the message: when the message is built (`err.Error()`, as the LSP and CLI do) the gain is about 2 allocs/op and 3-4% B/op per error (syntax_mid 21 -> 19 allocs). Because the message is now built on demand, `Error()` reflects any later mutation of the inner `*errors.Error` reached through `errors.As`; previously the text was fixed at creation. [W1-03] - **Linter L010**: scans bytes in a single pass instead of rebuilding substrings and running a regular expression; `BenchmarkLintRule/L010` is 67.5% faster on the diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 1e64ac9a7..7a7f9fbf5 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -434,8 +434,8 @@ func init() { // defer metrics.Disable() // // All operations are now tracked func Enable() { - metricsgate.Set(true) globalMetrics.startTime.Store(time.Now()) + metricsgate.Set(true) } // Disable deactivates metrics collection globally. diff --git a/pkg/metrics/pool_stats_test.go b/pkg/metrics/pool_stats_test.go index 949b0890e..a26c309d7 100644 --- a/pkg/metrics/pool_stats_test.go +++ b/pkg/metrics/pool_stats_test.go @@ -18,7 +18,6 @@ import ( "testing" "github.com/ajitpratap0/GoSQLX/internal/metricsgate" - "github.com/ajitpratap0/GoSQLX/internal/poolstats" "github.com/ajitpratap0/GoSQLX/pkg/metrics" ) From 9a7a5fee16a51a5940b1981e03048355389308e8 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 4 Nov 2026 02:06:20 +0530 Subject: [PATCH 3/4] test(metrics): restore metrics state symmetrically; changelog: report both runners [W1-07] Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- CHANGELOG.md | 2 +- pkg/gosqlx/pool_hygiene_test.go | 4 +++- pkg/transform/pool_hygiene_test.go | 4 +++- pkg/transpiler/pool_hygiene_test.go | 4 +++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56cbcdd5a..5eec6de14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,7 +105,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 benchmarks that publish diagnostics no longer time a growing buffer. ### Performance -- **Pools [W1-07]**: Skip atomic pool counter updates while metrics are disabled. amd64 layout-randomized perf gate: `PoolGetPut/serial` -5.41% (Hodges-Lehmann, p<0.001, A/A' +0.30%, spread 0.20%; benchstat 56.89 -> 54.03 ns/op), not in the low-power list; no gated E2E change (`ParseE2E/simple_select` -0.32%, p=0.543); allocs/op and B/op unchanged. The gain is mainly in contended/parallel use, which the gate does not measure. +- **Pools [W1-07]**: Skip atomic pool counter updates while metrics are disabled. Measured on the amd64 layout-randomized perf gate, `PoolGetPut/serial` is runner-dependent: -5.41% on an AMD EPYC 9V74 (Zen4, p<0.001), but +3.99% and +3.93% (p<0.001, reported as REGRESSION) on an EPYC 7763 (Zen3) for the current head, so no consistent improvement is claimed. The planned gain was <= -20%. allocs/op and B/op unchanged; the benefit is expected mainly in contended/parallel use, which the gate does not measure. - **Linter**: rule L007 (keyword case consistency) scans words without allocating (substring words, ASCII upper-casing into a stack buffer before the keyword lookup). Output is unchanged. On the canonical benchmarks (Apple M4, local), `BenchmarkLintRule/L007` allocates about 98% fewer objects (72565 to 1194 allocs/op) and about 97% fewer bytes, and `BenchmarkLintRW` allocates about 12.5% fewer objects. [W6-03] - **gosqlx**: errors returned by `Parse`, `Validate`, `Lint` and the tree helpers now wrap the sentinel and the underlying error in an unexported `wrapErr` that builds its message lazily, instead of `fmt.Errorf("%w: %w", ...)` on every failure. Error text, `errors.Is` and `errors.As` behaviour are unchanged; only the unexported concrete error type differs. The saving applies to callers that only check `errors.Is`/`errors.As` or drop the message: when the message is built (`err.Error()`, as the LSP and CLI do) the gain is about 2 allocs/op and 3-4% B/op per error (syntax_mid 21 -> 19 allocs). Because the message is now built on demand, `Error()` reflects any later mutation of the inner `*errors.Error` reached through `errors.As`; previously the text was fixed at creation. [W1-03] - **Linter L010**: scans bytes in a single pass instead of rebuilding diff --git a/pkg/gosqlx/pool_hygiene_test.go b/pkg/gosqlx/pool_hygiene_test.go index 1965f1eac..092f3daa0 100644 --- a/pkg/gosqlx/pool_hygiene_test.go +++ b/pkg/gosqlx/pool_hygiene_test.go @@ -29,7 +29,9 @@ func enablePoolStats(t testing.TB) { was := metrics.IsEnabled() metrics.Enable() t.Cleanup(func() { - if !was { + if was { + metrics.Enable() + } else { metrics.Disable() } }) diff --git a/pkg/transform/pool_hygiene_test.go b/pkg/transform/pool_hygiene_test.go index 074e20ec0..9e40f42c0 100644 --- a/pkg/transform/pool_hygiene_test.go +++ b/pkg/transform/pool_hygiene_test.go @@ -31,7 +31,9 @@ func enablePoolStats(t testing.TB) { was := metrics.IsEnabled() metrics.Enable() t.Cleanup(func() { - if !was { + if was { + metrics.Enable() + } else { metrics.Disable() } }) diff --git a/pkg/transpiler/pool_hygiene_test.go b/pkg/transpiler/pool_hygiene_test.go index 606abfb15..f629fcbd6 100644 --- a/pkg/transpiler/pool_hygiene_test.go +++ b/pkg/transpiler/pool_hygiene_test.go @@ -32,7 +32,9 @@ func enablePoolStats(t testing.TB) { was := metrics.IsEnabled() metrics.Enable() t.Cleanup(func() { - if !was { + if was { + metrics.Enable() + } else { metrics.Disable() } }) From d0169d989fc201013c77d8a5b79de4c3285dd0ae Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 4 Nov 2026 02:32:49 +0530 Subject: [PATCH 4/4] docs(changelog): report all three perf-gate runners [W1-07] Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eec6de14..00419e638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,7 +105,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 benchmarks that publish diagnostics no longer time a growing buffer. ### Performance -- **Pools [W1-07]**: Skip atomic pool counter updates while metrics are disabled. Measured on the amd64 layout-randomized perf gate, `PoolGetPut/serial` is runner-dependent: -5.41% on an AMD EPYC 9V74 (Zen4, p<0.001), but +3.99% and +3.93% (p<0.001, reported as REGRESSION) on an EPYC 7763 (Zen3) for the current head, so no consistent improvement is claimed. The planned gain was <= -20%. allocs/op and B/op unchanged; the benefit is expected mainly in contended/parallel use, which the gate does not measure. +- **Pools [W1-07]**: Skip atomic pool counter updates while metrics are disabled. Measured on the amd64 layout-randomized perf gate, `PoolGetPut/serial` is runner-dependent: -5.41% on an AMD EPYC 9V74 (Zen4, p<0.001), +3.99% and +3.93% (p<0.001, reported as REGRESSION) on an EPYC 7763 (Zen3) for an earlier head, and -37.06% (p<0.001) on an Intel Xeon 6973P-C for the final head, so the effect is CPU-dependent and no consistent improvement is claimed. The planned gain was <= -20%. allocs/op and B/op unchanged; the benefit is expected mainly in contended/parallel use, which the gate does not measure. - **Linter**: rule L007 (keyword case consistency) scans words without allocating (substring words, ASCII upper-casing into a stack buffer before the keyword lookup). Output is unchanged. On the canonical benchmarks (Apple M4, local), `BenchmarkLintRule/L007` allocates about 98% fewer objects (72565 to 1194 allocs/op) and about 97% fewer bytes, and `BenchmarkLintRW` allocates about 12.5% fewer objects. [W6-03] - **gosqlx**: errors returned by `Parse`, `Validate`, `Lint` and the tree helpers now wrap the sentinel and the underlying error in an unexported `wrapErr` that builds its message lazily, instead of `fmt.Errorf("%w: %w", ...)` on every failure. Error text, `errors.Is` and `errors.As` behaviour are unchanged; only the unexported concrete error type differs. The saving applies to callers that only check `errors.Is`/`errors.As` or drop the message: when the message is built (`err.Error()`, as the LSP and CLI do) the gain is about 2 allocs/op and 3-4% B/op per error (syntax_mid 21 -> 19 allocs). Because the message is now built on demand, `Error()` reflects any later mutation of the inner `*errors.Error` reached through `errors.As`; previously the text was fixed at creation. [W1-03] - **Linter L010**: scans bytes in a single pass instead of rebuilding