Skip to content

fix(linter): make Context.Nodes safe for concurrent rule Check [W6-05a] - #928

Merged
ajitpratap0 merged 5 commits into
mainfrom
perf/w6-05a-fixup
Nov 3, 2026
Merged

ajitpratap0 merged 5 commits into
mainfrom
perf/w6-05a-fixup

Conversation

@ajitpratap0

@ajitpratap0 ajitpratap0 commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Summary

Follow-up to merged #906 (review finding, Medium). Context.Nodes() filled its cache lazily without synchronisation, so several rules calling Check on one shared Context raced (confirmed under -race with SelectStar, DeleteWithoutWhere, ImplicitCrossJoin), contradicting pkg/linter/doc.go and rule.go.

Fix: lock-free. The nodes/nodesAST/nodesBuilt fields are replaced by one nodesCache unsafe.Pointer holding an immutable *nodeCollector (with a root *ast.AST field). Nodes() loads it atomically and returns it if root == c.AST; otherwise it walks (outside any lock) and publishes with atomic.StorePointer (last writer wins, contents identical). WithAST stores nil. There is no process-wide lock: an earlier revision used a package-level sync.RWMutex, which review measured as a cross-Context contention regression (see below), so it was dropped. Context stays copyable and vet-clean; no new allocations (the collector was already allocated). Same preorder as ast.Inspect; no pruning. Also corrects #906's Test Plan nit: merged head was 71bc2c5 (fully green), not b1bbae6.

Added BenchmarkLintParallel/{rules,lint} (b.RunParallel over all RW dialects) to the matrix and scripts/perf/gate-suite.txt. Limitation: the CI perf gate pins each benchmark to one CPU (-cpu 1, taskset), so on CI RunParallel runs a single goroutine and the gate line guards the serial cost of these paths only. It cannot see cross-Context contention. That is covered by the local multi-CPU A/B below (-cpu 8), which is what caught the RWMutex revision; a multi-CPU gate leg is a follow-up for the perf-gate work.

Test Plan

  • TestContextConcurrentRuleCheck (3 rules x 8 goroutines x 50 fresh Contexts): 74-77 DATA RACE reports on the base Nodes(), passes with the fix. TestLintContextHasNoCachedState field list updated to nodesCache.
  • go test -race -count=1 ./pkg/linter/...: 9/9 packages pass; go vet ./pkg/linter/... clean; staticcheck ./pkg/linter/... clean; task check exit 0; task test:cbinding pass; go test -count=1 ./pkg/compatibility/... pass.
  • Review finding (global RWMutex revision, head 970177d vs base f03332f, shared M4, n=8, interleaved): serial flat; at -cpu 8 rules phase +73.98% (3.898ms to 6.781ms, p=0.000), gosqlx.Lint +23.28% (15.74ms to 19.41ms, p=0.000).
  • Parallel A/B of THIS head (53ea2d2) vs base f03332f, same new benchmark in both trees, n=6 interleaved, shared M4 (indicative only):
benchmark base head delta
LintParallel/rules (-cpu 1) 17.26ms 17.26ms ~ (p=0.94)
LintParallel/rules-8 3.917ms 3.883ms ~ (p=0.09)
LintParallel/lint (-cpu 1) 30.44ms 30.48ms ~ (p=0.59)
LintParallel/lint-8 15.84ms 15.98ms ~ (p=0.24)

allocs/op -0.01% on all four; B/op +0.36% (rules), -0.14% to -0.22% (lint). Serial LintRW was +1% with the mutex revision; the atomic load removes that too.

  • amd64 CI perf gate (shadow), base 5b0818f8, AMD EPYC 7763: PASS. LintRW all 8 dialects -0.7% to -3.3% (none significant), LintRule L007/L010 neutral, ParseError neutral; allocs/op flat or -0.1%. Single-CPU, so it says nothing about contention (see Summary).
  • nodesCache comment now records why it is unsafe.Pointer rather than atomic.Pointer[nodeCollector]: atomic.Pointer embeds noCopy, and Context is copied by value, so vet copylocks would flag callers.

Breaking Changes

None. Behaviour is unchanged for single-goroutine use; concurrent use that was previously racy is now supported, as documented.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2

Nodes() filled its cache without synchronisation, so rules sharing one
Context raced, contradicting the documented concurrency contract. Guard the
cache with an RWMutex held only for pointer reads/writes, walk the tree
outside the lock with a Visitor (no per-statement closures), and add a -race
test running three rules concurrently on one Context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
@vercel

vercel Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
gosqlx-website Ready Ready Preview Nov 3, 2026 3:26pm UTC

@github-actions github-actions Bot added documentation Improvements or additions to documentation linter labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 22s —— View job


Review of PR #928: make Context.Nodes safe for concurrent rule Check

I read the diff but didn't run the tests. I'm relying on the PR's reported -race results.

Verdict: looks good, with a few non-blocking nits.

Correctness

  • The double-checked pattern is sound. The cache is read under RLock, the walk happens outside any lock, and the result is published under Lock after re-checking nodesBuilt && nodesAST == root. A losing goroutine returns the winner's slice, so every caller sees the same slice.
  • The Visitor is equivalent to the old ast.Inspect closure: preorder, no pruning, nil nodes skipped. Dropping the ast.Walk error is fine because Visit never returns one.
  • WithAST takes the write lock when it resets the cache, so it is consistent with Nodes.
  • Because root := c.AST is captured once, the cache is keyed to the AST that was actually walked. A stale cache can't be published under a newer c.AST.

Nits and considerations

  1. Package-global nodesMu (context.go, near line 168). Every Context in the process shares one RWMutex. Reads are very cheap, but heavy parallel linting of many files will still have all goroutines on the same RWMutex's reader count. That is cache-line contention, not blocking. The tradeoff (copyable Context, no copylocks) is reasonable and the ~1% cost is disclosed. If it ever shows up in profiles, a *sync.Once-style pointer field allocated in NewContext would remove the global. I'd leave it for now.
  2. Remaining race, which is documented. Nodes() reads c.AST without the lock. Assigning c.AST or calling WithAST while other goroutines call Nodes() is still a data race on the field. The doc comment now says that replacing the AST invalidates the cache, but it doesn't say that replacement must not overlap with concurrent Check calls. One extra sentence would make this explicit.
  3. Copying a populated Context. A copy shares nodes and nodesAST with the original. This is harmless because the slice is read-only. But if the copy then has its AST replaced, the nodesAST == c.AST check correctly invalidates it.
  4. Test coverage (nodes_concurrent_test.go).
    • It exercises the concurrent first-call path well (50 fresh Contexts × 8 goroutines).
    • It doesn't assert that all goroutines observe the same slice (for example, compare &Nodes()[0] across goroutines). That would pin the "one result is kept" guarantee.
    • It doesn't cover Nodes() racing with WithAST on the same Context. That is fine if unsupported, per nit 2.
    • Minor: t.Errorf from goroutines is safe here, and the per-iteration rule capture is fine on Go 1.22+.
  5. Performance. The ~1% LintRW cost is stated openly, and the Visitor avoids the extra closure allocations. Since the CI amd64 gate doesn't cover LintRW until the companion PR lands, I'd re-check the gate once it does.

Security

No concerns.

Docs

The CHANGELOG entry is accurate and matches the new contract.

…napshot [W6-05a]

Replace the process-wide nodesMu RWMutex with an atomically published
immutable *nodeCollector per Context, removing cross-Context contention
(rules phase +74% at 8 goroutines, Lint +23% with the global lock).
Add BenchmarkLintParallel to the matrix and gate suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 23s —— View job


Review of PR #928: make Context.Nodes safe for concurrent rule Check

I read the diff but didn't run the tests.

Verdict: the fix looks correct. I have a few non-blocking nits, and the PR description no longer matches the code.

The code changed since the earlier review

The earlier review in this thread described a package-global nodesMu RWMutex. The current head (53ea2d2, "lock-free per-Context snapshot") no longer has that lock. Nodes() now loads an immutable *nodeCollector through atomic.LoadPointer, and it publishes with atomic.StorePointer. This addresses the global-contention nit from that review.

Correctness

  • Each snapshot is fully built before it is published, and it is never mutated afterwards. A reader that loads the pointer therefore sees a complete slice. The ordering is correct.
  • The snapshot stores its own root, and Nodes() captures root := c.AST once. A cache built for an old AST can't be returned for a new one (p.root == c.AST).
  • Concurrent first callers can each walk the tree, and the last writer wins. The contents are identical, so this is harmless. The cost is duplicate work on the first call.
  • The Visitor walk matches the old ast.Inspect order (preorder, no pruning, nil skipped). Ignoring the ast.Walk error is fine because Visit never returns one.

Nits

  1. unsafe.Pointer instead of atomic.Pointer[nodeCollector]. I assume this is to keep Context copyable, because atomic.Pointer embeds noCopy and would trip vet's copylocks check. Please add a one-line comment saying that. Otherwise a later reader will "fix" it to atomic.Pointer and break vet. It also means the repo now has an unsafe import in a package that didn't need one.
  2. Copying a Context while another goroutine calls Nodes(). The struct copy reads nodesCache non-atomically, so it races with a concurrent StorePointer. This is an edge case, but the doc says "safe to call from several goroutines". Say that copying a Context must not overlap with concurrent use.
  3. c.AST replacement during concurrent use. Nodes() reads c.AST without synchronization, so WithAST or a direct c.AST = ... racing with Check is still a data race. Document that it must not overlap with concurrent Check calls.
  4. Doc comment formatting. The Nodes() comment has uneven line wrapping. One line is much longer than the rest, which looks like leftover from an edit. Re-wrap it.
  5. Test coverage (nodes_concurrent_test.go).
    • The test covers the race well.
    • Add an assertion that every goroutine gets a slice with the same length. If the same-slice guarantee matters, also compare &Nodes()[0] after the first-call race settles.
    • Also assert a rule-specific result, for example that SelectStar returns a violation. Right now it only checks err == nil and len(Nodes()) > 0.
  6. Gate suite. scripts/perf/gate-suite.txt adds BenchmarkLintParallel, but the description says the gate-suite addition is a separate PR (perf/gate-suite-lint) and that the CI gate doesn't include LintRW. Please confirm whether this change belongs in this PR. If it does, the first CI run on amd64 will be the real performance check.

Description is out of date

The PR body still says the cache is "guarded by an RWMutex" and that the cost is "one uncontended RLock per Nodes call". Neither matches the atomic snapshot implementation, and the A/B numbers (+1.0–1.3% sec/op) were measured on the earlier variant. Update the description and the CHANGELOG wording. If the atomic version was re-benchmarked, state those numbers.

Security

No concerns. The unsafe.Pointer use is limited to atomic pointer load and store of a single concrete type.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Perf gate: PASS (shadow mode: report only, this check never fails)

Base ddbd4f9edc1b, head adf0c057803d. 10 interleaved rounds, benchtime 200ms, -cpu 1, GOMEMLIMIT 64MiB, pinned to CPU 3 (SMT siblings 2-3) with taskset.

Runner CPU: AMD EPYC 9V45 96-Core Processor (x86_64). Base and head ran interleaved in this job, so the tables show their ratios only; absolute timings (raw files in the artifact) hold for this CPU and this job alone and are never compared across jobs.

  • 1 benchmark(s) faster by 3% or more

Rules: time regresses at >= +3% with p < 0.01 (Mann-Whitney U), or a suite geomean >= +1.5% (per-round geomeans, same test); allocs/op must not increase; B/op must not rise more than 2%; instructions/op must not rise more than 1.5%. An A/A control with more than 10% of benchmarks moving over 3% is noisy: re-run once, then inconclusive (neutral).

Gated suite (GOGC=off, GOMAXPROCS=1)

benchmark time p A/A B/op allocs/op status
ParseE2E/simple_select -0.83% 0.796 +0.54% +0.00% 5 → 5 ok
ParseE2E/select_where +0.00% 0.762 +1.55% +0.00% 18 → 18 ok
ParseE2E/join -1.86% 0.273 -2.94% +0.00% 17 → 17 ok
ParseE2E/aggregate +0.18% 0.853 -0.66% +0.00% 18 → 18 ok
ParseE2E/subquery +0.96% 0.345 +0.88% +0.00% 14 → 14 ok
ParseE2E/cte +1.66% 0.151 +2.38% +0.00% 21 → 21 ok
ParseE2E/window +2.69% 0.436 -2.46% +0.00% 11 → 11 ok
ParseE2E/complex +1.63% 0.190 +3.87% +0.00% 49 → 49 ok
ParseE2E/insert +4.95% 0.089 +2.38% +0.00% 17 → 17 ok
ParseE2E/update -0.25% 0.481 -1.14% +0.00% 13 → 13 ok
ParseE2E/delete +0.30% 0.684 +0.06% +0.00% 5 → 5 ok
ParseRW/postgresql/medium +1.58% 0.165 +1.22% +0.00% 646 → 646 ok
ParseRW/mysql/medium +1.29% 0.971 -0.89% +0.00% 368 → 368 ok
ParseRW/mariadb/medium +0.43% 0.971 +0.67% +0.00% 528 → 528 ok
ParseRW/sqlserver/medium +1.14% 0.218 +0.88% +0.00% 927 → 927 ok
ParseRW/oracle/medium +1.43% 0.247 -0.59% +0.00% 1090 → 1090 ok
ParseRW/sqlite/medium +5.27% 0.063 +1.91% +0.00% 347 → 347 ok
ParseRW/snowflake/medium +1.76% 0.190 -0.89% +0.00% 776 → 776 ok
ParseRW/clickhouse/medium +0.80% 0.912 +0.91% +0.00% 383 → 383 ok
ParseFresh/simple_select -1.79% 0.023 -0.52% +0.00% 6 → 6 ok
ParseFresh/join +1.39% 0.529 +0.78% +0.00% 18 → 18 ok
ParseFresh/complex +0.41% 0.796 +1.08% +0.00% 50 → 50 ok
PoolGetPut/serial -0.56% 0.218 -0.46% +0.00% 0 → 0 ok
Tokenize/scratch/join -0.90% 0.529 -0.28% +0.00% 0 → 0 ok
Tokenize/scratch/complex -0.51% 0.496 +0.23% +0.00% 0 → 0 ok
Tokenize/scratch/insert -0.77% 0.436 -0.23% +0.00% 0 → 0 ok
Normalize/simple_select -1.55% 0.070 +0.48% +0.00% 7 → 7 ok
Normalize/join +1.09% 0.650 +0.57% +0.00% 23 → 23 ok
Normalize/complex -0.67% 0.241 +1.09% +0.00% 68 → 68 ok
TransformParse/simple_select -2.24% 0.579 -1.35% +0.00% 5 → 5 ok
TransformParse/join +1.69% 0.199 +0.70% +0.00% 17 → 17 ok
TransformParse/complex -0.56% 0.684 -0.14% +0.00% 49 → 49 ok
LintRW/postgresql +4.95% 0.353 +2.49% -0.13% 34185 → 34187 ok
LintRW/mysql -0.04% 0.853 +5.84% -0.15% 41488 → 41488 ok
LintRW/mariadb +0.44% 0.796 +4.26% -0.15% 38234 → 38234 ok
LintRW/sqlserver -1.28% 0.739 +1.98% -0.13% 34650 → 34619 -allocs
LintRW/oracle -3.56% 0.436 -2.68% -0.13% 42243 → 42243 ok
LintRW/sqlite +0.12% 0.529 -0.57% -0.14% 37576 → 37576 ok
LintRW/snowflake -2.91% 0.631 -2.61% -0.13% 37558 → 37558 ok
LintRW/clickhouse -0.16% 0.436 +0.50% -0.14% 42342 → 42342 ok
LintRule/L007 +0.70% 0.739 +3.55% +0.00% 72565 → 72565 ok
LintRule/L010 -9.79% <0.001 +0.67% +0.00% 0 → 0 faster
ParseError/syntax_mid/parse +1.60% 0.472 +2.25% +0.00% 12 → 12 ok
ParseError/syntax_mid/validate +1.09% 0.247 +2.22% +0.00% 12 → 12 ok
ParseError/syntax_end/parse +0.80% 0.449 +0.60% +0.00% 16 → 16 ok
ParseError/syntax_end/validate +0.86% 0.545 +0.82% +0.00% 16 → 16 ok
ParseError/tokenize/parse +1.89% 0.684 +1.51% +0.00% 4 → 4 ok
ParseError/tokenize/validate -0.21% 0.734 +1.01% +0.00% 21 → 21 ok
ParseError/long_prefix/parse +1.46% 0.045 +0.94% +0.00% 219 → 219 ok
ParseError/long_prefix/validate +0.92% 0.796 +0.84% +0.00% 219 → 219 ok
geomean +0.28% 0.912 +0.65%

A/A control: 4 of 50 benchmarks moved more than 3%.

New in head, no base to compare (not judged): LintParallel/rules, LintParallel/lint

Instructions per operation (cmd/perfdriver)

case instructions/op status
e2e/aggregate +0.02% ok
e2e/complex -0.00% ok
e2e/cte -0.03% ok
e2e/delete -0.02% ok
e2e/insert +0.01% ok
e2e/join +0.02% ok
e2e/select_where -0.01% ok
e2e/simple_select +0.05% ok
e2e/subquery +0.02% ok
e2e/update -0.08% ok
e2e/window +0.04% ok
rw/postgresql/medium +0.05% ok
Secondary metric: default GC, GOMAXPROCS=1 (informational)
benchmark time p
ParseE2E/simple_select +1.46% 0.684
ParseE2E/select_where +0.12% 0.315
ParseE2E/join -1.12% 1.000
ParseE2E/aggregate +1.30% 0.970
ParseE2E/subquery -0.44% 0.496
ParseE2E/cte -1.13% 0.684
ParseE2E/window +0.41% 1.000
ParseE2E/complex +2.39% 0.247
ParseE2E/insert +0.79% 0.345
ParseE2E/update +2.38% 0.481
ParseE2E/delete +0.61% 0.631
ParseRW/postgresql/medium -0.74% 0.853
ParseRW/mysql/medium -1.32% 0.853
ParseRW/mariadb/medium -2.52% 0.529
ParseRW/sqlserver/medium +1.50% 0.529
ParseRW/oracle/medium +2.51% 0.912
ParseRW/sqlite/medium +0.79% 0.315
ParseRW/snowflake/medium -0.26% 0.353
ParseRW/clickhouse/medium +1.77% 0.393
ParseFresh/simple_select -1.06% 0.199
ParseFresh/join -2.63% 0.684
ParseFresh/complex -2.05% 0.739
PoolGetPut/serial -0.46% 0.436
Tokenize/scratch/join -0.72% 0.631
Tokenize/scratch/complex +0.74% 0.791
Tokenize/scratch/insert +0.03% 0.739
Normalize/simple_select -2.47% 0.571
Normalize/join -2.05% 0.579
Normalize/complex +3.27% 0.529
TransformParse/simple_select -2.26% 0.684
TransformParse/join -1.75% 0.481
TransformParse/complex +1.33% 0.739
LintRW/postgresql -1.17% 0.739
LintRW/mysql -2.21% 0.796
LintRW/mariadb -1.68% 0.853
LintRW/sqlserver +0.84% 0.853
LintRW/oracle +0.78% 0.912
LintRW/sqlite -2.87% 0.393
LintRW/snowflake -2.03% 1.000
LintRW/clickhouse +3.84% 0.684
LintRule/L007 -3.95% 0.315
LintRule/L010 -9.16% 0.011
ParseError/syntax_mid/parse +0.06% 0.940
ParseError/syntax_mid/validate -3.94% 0.104
ParseError/syntax_end/parse -3.27% 0.162
ParseError/syntax_end/validate -1.51% 0.631
ParseError/tokenize/parse -0.58% 1.000
ParseError/tokenize/validate -3.61% 0.545
ParseError/long_prefix/parse -4.95% 0.075
ParseError/long_prefix/validate -1.18% 0.529
geomean -0.79%
Environment
date:        2026-11-03T15:26:43Z
base:        ddbd4f9edc1b408714401ddd2f073895744e1e6f
head:        working tree of adf0c057803d51227bbf60727397706f9c02352b
cpu model:   AMD EPYC 9V45 96-Core Processor (x86_64)
go:          go version go1.26.9 linux/amd64
kernel:      Linux 7.0.0-1017-azure x86_64
pinning:     pinned to CPU 3 (SMT siblings 2-3) with taskset
gcoff env:   GOGC=off GOMEMLIMIT=64MiB
clocksource: tsc
load:        1.43, 0.38, 0.13
instr count: cachegrind
perf probe:  Error: No supported events found. The instructions:u event is not supported. 

Architecture:                            x86_64
CPU(s):                                  4
Model name:                              AMD EPYC 9V45 96-Core Processor
Thread(s) per core:                      2
Core(s) per socket:                      2
Socket(s):                               1
Hypervisor vendor:                       Microsoft
L1d cache:                               96 KiB (2 instances)
L1i cache:                               64 KiB (2 instances)
L2 cache:                                2 MiB (2 instances)
L3 cache:                                32 MiB (1 instance)
benchstat (primary metric)
goos: linux
goarch: amd64
pkg: github.com/ajitpratap0/GoSQLX/pkg/fingerprint
cpu: AMD EPYC 9V45 96-Core Processor                
                        │    base     │                head                │
                        │   sec/op    │   sec/op     vs base               │
Normalize/simple_select   1.262µ ± 2%   1.242µ ± 2%       ~ (p=0.065 n=10)
Normalize/join            3.843µ ± 4%   3.885µ ± 2%       ~ (p=0.645 n=10)
Normalize/complex         9.932µ ± 4%   9.865µ ± 4%       ~ (p=0.239 n=10)
geomean                   3.638µ        3.625µ       -0.38%

                        │     base     │                 head                  │
                        │     B/op     │     B/op      vs base                 │
Normalize/simple_select     896.0 ± 0%     896.0 ± 0%       ~ (p=1.000 n=10) ¹
Normalize/join            2.211Ki ± 0%   2.211Ki ± 0%       ~ (p=1.000 n=10) ¹
Normalize/complex         6.117Ki ± 0%   6.117Ki ± 0%       ~ (p=1.000 n=10) ¹
geomean                   2.279Ki        2.279Ki       +0.00%
¹ all samples are equal

                        │    base    │                head                 │
                        │ allocs/op  │ allocs/op   vs base                 │
Normalize/simple_select   7.000 ± 0%   7.000 ± 0%       ~ (p=1.000 n=10) ¹
Normalize/join            23.00 ± 0%   23.00 ± 0%       ~ (p=1.000 n=10) ¹
Normalize/complex         68.00 ± 0%   68.00 ± 0%       ~ (p=1.000 n=10) ¹
geomean                   22.20        22.20       +0.00%
¹ all samples are equal

pkg: github.com/ajitpratap0/GoSQLX/pkg/gosqlx
                                │     base     │                head                 │
                                │    sec/op    │    sec/op     vs base               │
ParseE2E/simple_select            802.9n ±  5%   796.2n ± 15%       ~ (p=0.796 n=10)
ParseE2E/select_where             2.038µ ±  2%   2.038µ ±  1%       ~ (p=0.754 n=10)
ParseE2E/join                     2.964µ ±  4%   2.909µ ±  3%       ~ (p=0.271 n=10)
ParseE2E/aggregate                2.197µ ±  2%   2.201µ ±  2%       ~ (p=0.853 n=10)
ParseE2E/subquery                 1.884µ ±  5%   1.902µ ±  6%       ~ (p=0.342 n=10)
ParseE2E/cte                      2.715µ ±  4%   2.760µ ±  5%       ~ (p=0.148 n=10)
ParseE2E/window                   1.711µ ±  9%   1.757µ ± 11%       ~ (p=0.436 n=10)
ParseE2E/complex                  7.347µ ±  3%   7.467µ ±  3%       ~ (p=0.190 n=10)
ParseE2E/insert                   1.322µ ±  4%   1.388µ ±  6%       ~ (p=0.086 n=10)
ParseE2E/update                   1.189µ ±  5%   1.186µ ±  3%       ~ (p=0.481 n=10)
ParseE2E/delete                   716.1n ±  1%   718.2n ±  3%       ~ (p=0.684 n=10)
ParseRW/postgresql/medium         80.25µ ±  2%   81.53µ ±  7%       ~ (p=0.165 n=10)
ParseRW/mysql/medium              52.83µ ± 18%   53.52µ ±  5%       ~ (p=0.971 n=10)
ParseRW/mariadb/medium            75.29µ ±  4%   75.61µ ±  3%       ~ (p=0.971 n=10)
ParseRW/sqlserver/medium          141.7µ ±  1%   143.3µ ±  5%       ~ (p=0.218 n=10)
ParseRW/oracle/medium             177.7µ ±  5%   180.2µ ±  5%       ~ (p=0.247 n=10)
ParseRW/sqlite/medium             42.21µ ±  7%   44.43µ ±  6%       ~ (p=0.063 n=10)
ParseRW/snowflake/medium          106.5µ ±  5%   108.4µ ±  7%       ~ (p=0.190 n=10)
ParseRW/clickhouse/medium         49.77µ ±  8%   50.17µ ±  4%       ~ (p=0.912 n=10)
ParseFresh/simple_select          4.421µ ± 10%   4.342µ ±  1%       ~ (p=0.020 n=10)
ParseFresh/join                   6.379µ ± 10%   6.467µ ± 12%       ~ (p=0.529 n=10)
ParseFresh/complex                10.49µ ±  3%   10.53µ ±  2%       ~ (p=0.796 n=10)
PoolGetPut/serial                 62.18n ±  1%   61.83n ±  1%       ~ (p=0.218 n=10)
ParseError/syntax_mid/parse       1.310µ ±  5%   1.332µ ±  2%       ~ (p=0.469 n=10)
ParseError/syntax_mid/validate    1.241µ ±  4%   1.254µ ±  2%       ~ (p=0.247 n=10)
ParseError/syntax_end/parse       1.502µ ±  2%   1.514µ ± 11%       ~ (p=0.447 n=10)
ParseError/syntax_end/validate    1.517µ ±  3%   1.530µ ±  4%       ~ (p=0.542 n=10)
ParseError/tokenize/parse         488.3n ± 15%   497.5n ±  3%       ~ (p=0.684 n=10)
ParseError/tokenize/validate      1.631µ ± 15%   1.627µ ±  6%       ~ (p=0.725 n=10)
ParseError/long_prefix/parse      38.10µ ±  4%   38.65µ ±  3%       ~ (p=0.041 n=10)
ParseError/long_prefix/validate   36.22µ ±  5%   36.55µ ±  5%       ~ (p=0.796 n=10)
geomean                           5.526µ         5.581µ        +0.99%

                                │     base      │                 head                 │
                                │      B/s      │      B/s       vs base               │
ParseE2E/simple_select            30.88Mi ±  5%   31.15Mi ± 13%       ~ (p=0.796 n=10)
ParseE2E/select_where             48.21Mi ±  2%   48.21Mi ±  1%       ~ (p=0.739 n=10)
ParseE2E/join                     40.54Mi ±  4%   41.31Mi ±  3%       ~ (p=0.271 n=10)
ParseE2E/aggregate                36.46Mi ±  2%   36.40Mi ±  2%       ~ (p=0.853 n=10)
ParseE2E/subquery                 41.51Mi ±  5%   41.12Mi ±  6%       ~ (p=0.342 n=10)
ParseE2E/cte                      43.56Mi ±  4%   42.84Mi ±  5%       ~ (p=0.149 n=10)
ParseE2E/window                   52.44Mi ±  8%   51.04Mi ± 10%       ~ (p=0.436 n=10)
ParseE2E/complex                  49.08Mi ±  3%   48.28Mi ±  3%       ~ (p=0.190 n=10)
ParseE2E/insert                   54.84Mi ±  4%   52.24Mi ±  6%       ~ (p=0.086 n=10)
ParseE2E/update                   44.12Mi ±  4%   44.24Mi ±  3%       ~ (p=0.481 n=10)
ParseE2E/delete                   69.26Mi ±  1%   69.05Mi ±  3%       ~ (p=0.684 n=10)
ParseRW/postgresql/medium         44.71Mi ±  2%   44.01Mi ±  6%       ~ (p=0.165 n=10)
ParseRW/mysql/medium              51.23Mi ± 15%   50.57Mi ±  5%       ~ (p=0.971 n=10)
ParseRW/mariadb/medium            47.09Mi ±  4%   46.89Mi ±  3%       ~ (p=0.971 n=10)
ParseRW/sqlserver/medium          46.96Mi ±  1%   46.42Mi ±  5%       ~ (p=0.218 n=10)
ParseRW/oracle/medium             47.18Mi ±  5%   46.52Mi ±  4%       ~ (p=0.247 n=10)
ParseRW/sqlite/medium             48.94Mi ±  6%   46.50Mi ±  6%       ~ (p=0.063 n=10)
ParseRW/snowflake/medium          47.73Mi ±  5%   46.90Mi ±  6%       ~ (p=0.190 n=10)
ParseRW/clickhouse/medium         45.28Mi ±  8%   44.92Mi ±  4%       ~ (p=0.912 n=10)
ParseError/syntax_mid/parse       34.93Mi ±  5%   34.38Mi ±  2%       ~ (p=0.470 n=10)
ParseError/syntax_mid/validate    36.91Mi ±  3%   36.51Mi ±  2%       ~ (p=0.247 n=10)
ParseError/syntax_end/parse       24.76Mi ±  2%   24.56Mi ± 10%       ~ (p=0.436 n=10)
ParseError/syntax_end/validate    24.53Mi ±  3%   24.32Mi ±  4%       ~ (p=0.579 n=10)
ParseError/tokenize/parse         89.85Mi ± 13%   88.17Mi ±  3%       ~ (p=0.684 n=10)
ParseError/tokenize/validate      26.91Mi ± 13%   26.96Mi ±  6%       ~ (p=0.725 n=10)
ParseError/long_prefix/parse      60.63Mi ±  4%   59.76Mi ±  2%       ~ (p=0.052 n=10)
ParseError/long_prefix/validate   63.77Mi ±  5%   63.20Mi ±  4%       ~ (p=0.756 n=10)
geomean                           44.48Mi         43.97Mi        -1.15%

                                │      base      │                 head                  │
                                │      B/op      │     B/op      vs base                 │
ParseE2E/simple_select              816.0 ± 0%       816.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/select_where             1.523Ki ± 0%     1.523Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/join                     1.836Ki ± 0%     1.836Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/aggregate                1.812Ki ± 0%     1.812Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/subquery                 1.828Ki ± 0%     1.828Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/cte                      2.336Ki ± 0%     2.336Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/window                   1.281Ki ± 0%     1.281Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/complex                  5.023Ki ± 0%     5.023Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/insert                     824.0 ± 0%       824.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/update                     736.0 ± 0%       736.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/delete                     400.0 ± 0%       400.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseRW/postgresql/medium         65.86Ki ± 0%     65.86Ki ± 0%       ~ (p=0.837 n=10)
ParseRW/mysql/medium              42.36Ki ± 0%     42.36Ki ± 0%       ~ (p=1.000 n=10)
ParseRW/mariadb/medium            60.56Ki ± 0%     60.56Ki ± 0%       ~ (p=0.370 n=10)
ParseRW/sqlserver/medium          83.65Ki ± 0%     83.65Ki ± 0%       ~ (p=0.675 n=10)
ParseRW/oracle/medium             104.8Ki ± 0%     104.8Ki ± 0%       ~ (p=0.233 n=10)
ParseRW/sqlite/medium             38.12Ki ± 0%     38.12Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseRW/snowflake/medium          77.63Ki ± 0%     77.63Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseRW/clickhouse/medium         34.82Ki ± 0%     34.82Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseFresh/simple_select            896.0 ± 0%       896.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseFresh/join                   1.914Ki ± 0%     1.914Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseFresh/complex                5.102Ki ± 0%     5.102Ki ± 0%       ~ (p=1.000 n=10) ¹
PoolGetPut/serial                   0.000 ± 0%       0.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/syntax_mid/parse         680.0 ± 0%       680.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/syntax_mid/validate      680.0 ± 0%       680.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/syntax_end/parse       1.430Ki ± 0%     1.430Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseError/syntax_end/validate    1.430Ki ± 0%     1.430Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseError/tokenize/parse           272.0 ± 0%       272.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/tokenize/validate      1.758Ki ± 0%     1.758Ki ± 0%       ~ (p=1.000 n=10) ¹
ParseError/long_prefix/parse      22.70Ki ± 0%     22.70Ki ± 0%       ~ (p=0.224 n=10)
ParseError/long_prefix/validate   22.68Ki ± 0%     22.68Ki ± 0%       ~ (p=1.000 n=10) ¹
geomean                                        ²                 +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                │     base      │                 head                 │
                                │   allocs/op   │  allocs/op   vs base                 │
ParseE2E/simple_select             5.000 ± 0%      5.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/select_where              18.00 ± 0%      18.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/join                      17.00 ± 0%      17.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/aggregate                 18.00 ± 0%      18.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/subquery                  14.00 ± 0%      14.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/cte                       21.00 ± 0%      21.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/window                    11.00 ± 0%      11.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/complex                   49.00 ± 0%      49.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/insert                    17.00 ± 0%      17.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/update                    13.00 ± 0%      13.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseE2E/delete                    5.000 ± 0%      5.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseRW/postgresql/medium          646.0 ± 0%      646.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseRW/mysql/medium               368.0 ± 0%      368.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseRW/mariadb/medium             528.0 ± 0%      528.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseRW/sqlserver/medium           927.0 ± 0%      927.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseRW/oracle/medium             1.090k ± 0%     1.090k ± 0%       ~ (p=1.000 n=10) ¹
ParseRW/sqlite/medium              347.0 ± 0%      347.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseRW/snowflake/medium           776.0 ± 0%      776.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseRW/clickhouse/medium          383.0 ± 0%      383.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseFresh/simple_select           6.000 ± 0%      6.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseFresh/join                    18.00 ± 0%      18.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseFresh/complex                 50.00 ± 0%      50.00 ± 0%       ~ (p=1.000 n=10) ¹
PoolGetPut/serial                  0.000 ± 0%      0.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/syntax_mid/parse        12.00 ± 0%      12.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/syntax_mid/validate     12.00 ± 0%      12.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/syntax_end/parse        16.00 ± 0%      16.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/syntax_end/validate     16.00 ± 0%      16.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/tokenize/parse          4.000 ± 0%      4.000 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/tokenize/validate       21.00 ± 0%      21.00 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/long_prefix/parse       219.0 ± 0%      219.0 ± 0%       ~ (p=1.000 n=10) ¹
ParseError/long_prefix/validate    219.0 ± 0%      219.0 ± 0%       ~ (p=1.000 n=10) ¹
geomean                                       ²                +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                          │     base     │                head                │
                          │   sec/stmt   │  sec/stmt    vs base               │
ParseRW/postgresql/medium   6.688µ ±  2%   6.794µ ± 7%       ~ (p=0.165 n=10)
ParseRW/mysql/medium        6.604µ ± 18%   6.690µ ± 5%       ~ (p=0.971 n=10)
ParseRW/mariadb/medium      8.366µ ±  4%   8.402µ ± 3%       ~ (p=0.971 n=10)
ParseRW/sqlserver/medium    6.748µ ±  1%   6.825µ ± 5%       ~ (p=0.218 n=10)
ParseRW/oracle/medium       7.403µ ±  5%   7.509µ ± 5%       ~ (p=0.247 n=10)
ParseRW/sqlite/medium       7.034µ ±  7%   7.405µ ± 6%       ~ (p=0.063 n=10)
ParseRW/snowflake/medium    8.195µ ±  5%   8.339µ ± 7%       ~ (p=0.190 n=10)
ParseRW/clickhouse/medium   7.111µ ±  8%   7.168µ ± 4%       ~ (p=0.912 n=10)
geomean                     7.242µ         7.365µ       +1.71%

pkg: github.com/ajitpratap0/GoSQLX/pkg/linter
                   │    base     │                head                 │
                   │   sec/op    │    sec/op     vs base               │
LintRW/postgresql    3.730m ± 6%   3.915m ±  7%       ~ (p=0.353 n=10)
LintRW/mysql         4.589m ± 3%   4.587m ± 11%       ~ (p=0.853 n=10)
LintRW/mariadb       4.256m ± 5%   4.275m ±  7%       ~ (p=0.796 n=10)
LintRW/sqlserver     4.021m ± 4%   3.970m ±  9%       ~ (p=0.739 n=10)
LintRW/oracle        5.005m ± 7%   4.826m ±  5%       ~ (p=0.436 n=10)
LintRW/sqlite        4.019m ± 6%   4.024m ±  4%       ~ (p=0.529 n=10)
LintRW/snowflake     4.308m ± 6%   4.182m ±  2%       ~ (p=0.631 n=10)
LintRW/clickhouse    4.651m ± 4%   4.644m ±  6%       ~ (p=0.436 n=10)
LintRule/L007        5.629m ± 7%   5.668m ±  4%       ~ (p=0.739 n=10)
LintRule/L010        557.0µ ± 2%   502.5µ ±  2%  -9.79% (p=0.000 n=10)
LintParallel/rules                 23.77m ±  9%
LintParallel/lint                  34.93m ±  2%
geomean              3.604m        5.045m        -1.22%

                  │     base     │                 head                 │
                  │     B/s      │      B/s       vs base               │
LintRW/postgresql   7.591Mi ± 5%   7.234Mi ±  8%       ~ (p=0.363 n=10)
LintRW/mysql        7.129Mi ± 3%   7.133Mi ± 10%       ~ (p=0.754 n=10)
LintRW/mariadb      7.167Mi ± 4%   7.138Mi ±  7%       ~ (p=0.782 n=10)
LintRW/sqlserver    7.701Mi ± 4%   7.801Mi ±  8%       ~ (p=0.697 n=10)
LintRW/oracle       7.935Mi ± 7%   8.230Mi ±  4%       ~ (p=0.435 n=10)
LintRW/sqlite       7.143Mi ± 5%   7.138Mi ±  4%       ~ (p=0.446 n=10)
LintRW/snowflake    7.238Mi ± 7%   7.458Mi ±  2%       ~ (p=0.631 n=10)
LintRW/clickhouse   6.981Mi ± 4%   6.990Mi ±  6%       ~ (p=0.447 n=10)
geomean             7.354Mi        7.380Mi        +0.36%

                   │      base      │                 head                  │
                   │      B/op      │     B/op      vs base                 │
LintRW/postgresql    3.406Mi ± 0%     3.402Mi ± 0%  -0.13% (p=0.000 n=10)
LintRW/mysql         4.250Mi ± 0%     4.243Mi ± 0%  -0.15% (p=0.000 n=10)
LintRW/mariadb       3.930Mi ± 0%     3.924Mi ± 0%  -0.15% (p=0.000 n=10)
LintRW/sqlserver     3.493Mi ± 0%     3.488Mi ± 0%  -0.13% (p=0.000 n=10)
LintRW/oracle        4.247Mi ± 0%     4.242Mi ± 0%  -0.13% (p=0.000 n=10)
LintRW/sqlite        3.702Mi ± 0%     3.697Mi ± 0%  -0.14% (p=0.000 n=10)
LintRW/snowflake     3.750Mi ± 0%     3.745Mi ± 0%  -0.13% (p=0.000 n=10)
LintRW/clickhouse    4.196Mi ± 0%     4.190Mi ± 0%  -0.14% (p=0.000 n=10)
LintRule/L007        2.942Mi ± 0%     2.942Mi ± 0%       ~ (p=0.474 n=10)
LintRule/L010          0.000 ± 0%       0.000 ± 0%       ~ (p=1.000 n=10) ¹
LintParallel/rules                    10.96Mi ± 0%
LintParallel/lint                     30.93Mi ± 0%
geomean                           ²                 -0.11%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                   │     base      │                 head                 │
                   │   allocs/op   │  allocs/op   vs base                 │
LintRW/postgresql    34.19k ± 0%     34.19k ± 0%       ~ (p=0.057 n=10)
LintRW/mysql         41.49k ± 0%     41.49k ± 0%       ~ (p=1.000 n=10)
LintRW/mariadb       38.23k ± 0%     38.23k ± 0%       ~ (p=0.582 n=10)
LintRW/sqlserver     34.65k ± 0%     34.62k ± 0%  -0.09% (p=0.000 n=10)
LintRW/oracle        42.24k ± 0%     42.24k ± 0%       ~ (p=1.000 n=10) ¹
LintRW/sqlite        37.58k ± 0%     37.58k ± 0%       ~ (p=1.000 n=10)
LintRW/snowflake     37.56k ± 0%     37.56k ± 0%       ~ (p=1.000 n=10) ¹
LintRW/clickhouse    42.34k ± 0%     42.34k ± 0%       ~ (p=0.474 n=10)
LintRule/L007        72.56k ± 0%     72.56k ± 0%       ~ (p=1.000 n=10) ¹
LintRule/L010         0.000 ± 0%      0.000 ± 0%       ~ (p=1.000 n=10) ¹
LintParallel/rules                   229.7k ± 0%
LintParallel/lint                    308.3k ± 0%
geomean                          ²                -0.01%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                  │    base     │                head                 │
                  │  sec/stmt   │   sec/stmt    vs base               │
LintRW/postgresql   12.23µ ± 6%   12.83µ ±  7%       ~ (p=0.353 n=10)
LintRW/mysql        11.09µ ± 3%   11.08µ ± 11%       ~ (p=0.811 n=10)
LintRW/mariadb      11.20µ ± 5%   11.25µ ±  7%       ~ (p=0.796 n=10)
LintRW/sqlserver    14.52µ ± 4%   14.33µ ±  9%       ~ (p=0.739 n=10)
LintRW/oracle       13.60µ ± 7%   13.12µ ±  5%       ~ (p=0.424 n=10)
LintRW/sqlite       11.55µ ± 6%   11.56µ ±  4%       ~ (p=0.529 n=10)
LintRW/snowflake    13.17µ ± 6%   12.79µ ±  2%       ~ (p=0.631 n=10)
LintRW/clickhouse   11.80µ ± 4%   11.79µ ±  6%       ~ (p=0.436 n=10)
LintRule/L007       2.001µ ± 7%   2.015µ ±  4%       ~ (p=0.739 n=10)
LintRule/L010       198.0n ± 2%   178.6n ±  2%  -9.80% (p=0.000 n=10)
geomean             6.806µ        6.723µ        -1.22%

pkg: github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer
                         │    base     │                head                │
                         │   sec/op    │   sec/op     vs base               │
Tokenize/scratch/join      813.5n ± 2%   806.3n ± 3%       ~ (p=0.529 n=10)
Tokenize/scratch/complex   2.164µ ± 1%   2.153µ ± 8%       ~ (p=0.493 n=10)
Tokenize/scratch/insert    395.3n ± 2%   392.2n ± 3%       ~ (p=0.436 n=10)
geomean                    886.2n        879.7n       -0.73%

                         │     base     │                head                 │
                         │     B/s      │     B/s       vs base               │
Tokenize/scratch/join      147.7Mi ± 2%   149.0Mi ± 3%       ~ (p=0.529 n=10)
Tokenize/scratch/complex   166.6Mi ± 1%   167.4Mi ± 8%       ~ (p=0.529 n=10)
Tokenize/scratch/insert    183.4Mi ± 2%   184.8Mi ± 3%       ~ (p=0.436 n=10)
geomean                    165.3Mi        166.4Mi       +0.72%

                         │     base     │                head                 │
                         │     B/op     │    B/op     vs base                 │
Tokenize/scratch/join      0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Tokenize/scratch/complex   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Tokenize/scratch/insert    0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
geomean                               ²               +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                         │     base     │                head                 │
                         │  allocs/op   │ allocs/op   vs base                 │
Tokenize/scratch/join      0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Tokenize/scratch/complex   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
Tokenize/scratch/insert    0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
geomean                               ²               +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/ajitpratap0/GoSQLX/pkg/transform
                             │     base     │                head                 │
                             │    sec/op    │    sec/op     vs base               │
TransformParse/simple_select   768.8n ± 18%   751.5n ± 10%       ~ (p=0.579 n=10)
TransformParse/join            2.932µ ±  4%   2.981µ ± 10%       ~ (p=0.197 n=10)
TransformParse/complex         7.346µ ±  6%   7.305µ ± 12%       ~ (p=0.684 n=10)
geomean                        2.549µ         2.539µ        -0.38%

                             │     base     │                 head                  │
                             │     B/op     │     B/op      vs base                 │
TransformParse/simple_select     816.0 ± 0%     816.0 ± 0%       ~ (p=1.000 n=10) ¹
TransformParse/join            1.836Ki ± 0%   1.836Ki ± 0%       ~ (p=1.000 n=10) ¹
TransformParse/complex         5.023Ki ± 0%   5.023Ki ± 0%       ~ (p=1.000 n=10) ¹
geomean                        1.944Ki        1.944Ki       +0.00%
¹ all samples are equal

                             │    base    │                head                 │
                             │ allocs/op  │ allocs/op   vs base                 │
TransformParse/simple_select   5.000 ± 0%   5.000 ± 0%       ~ (p=1.000 n=10) ¹
TransformParse/join            17.00 ± 0%   17.00 ± 0%       ~ (p=1.000 n=10) ¹
TransformParse/complex         49.00 ± 0%   49.00 ± 0%       ~ (p=1.000 n=10) ¹
geomean                        16.09        16.09       +0.00%
¹ all samples are equal

Run, raw data and benchstat

Resolve CHANGELOG (and gate-suite) conflicts by placing this PR's entries in main's Unreleased sections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnbRnTFErhoMJdAjVEeCg2
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 19s —— View job


Review of PR #928: lock-free Context.Nodes()

I read the diff at the current head. I didn't run the tests, so I'm relying on the PR's reported -race results and the CI perf gate.

Verdict: the fix is correct and I'd merge it. The nits below are non-blocking.

Correctness

  • Snapshot publication. Each nodeCollector is fully built before atomic.StorePointer and never mutated afterwards. Readers that load it see a complete slice.
  • Stale-AST check. root := c.AST is captured once and stored in the snapshot. A cache built for an old AST can't be returned for a new one (p.root == c.AST).
  • Concurrent first calls. They may each walk the tree and the last writer wins. The contents are identical, so this only costs duplicate work.
  • Walk order. The Visitor walk matches ast.Inspect preorder with no pruning and nil nodes skipped. Dropping the ast.Walk error is fine because Visit never returns one.
  • Nil AST. A nil AST caches an empty snapshot with root == nil. WithAST(nil, …) then still matches, which is correct.

Nits

  1. unsafe.Pointer rationale (context.go, nodesCache field). It looks deliberate: atomic.Pointer[T] would trip copylocks and make Context non-copyable. Please say so in the field comment, or a later cleanup will "fix" it and break vet.
  2. Doc comment (Nodes()). One line is much longer than the rest ("…one result is kept. Rules must not mutate…"), so re-wrap it. The comment also doesn't state the remaining limits, and these should be added:
    • Replacing c.AST or calling WithAST must not overlap with concurrent Nodes()/Check calls. c.AST is read unsynchronised.
    • Copying a Context must not overlap with concurrent use. The struct copy reads nodesCache non-atomically.
  3. Test strength (nodes_concurrent_test.go).
    • It only asserts err == nil and len(Nodes()) > 0. Add a check that all goroutines see the same length, and that a rule-specific result holds. For example, SelectStar should report a violation on this SQL.
    • Consider checking that the slice contents match a plain ast.Inspect walk. That would pin the "same preorder" claim.
  4. Gate suite. scripts/perf/gate-suite.txt already has BenchmarkLintRW on this head. The PR body says it is awaiting ci(perf): gate LintRW, LintRule L007/L010 and ParseError in the shadow suite #927, so the body is slightly stale on this point. The shadow gate lists LintParallel/* as "new in head, no base", so it is not yet judged against a base. The A/B table in the PR description is the only comparison for it.
  5. PR body and CHANGELOG. The body is now consistent with the atomic design, and the CHANGELOG entry is accurate. The LintParallel rows in the perf gate output are single-head numbers (no -cpu 8), so they don't show contention. The -cpu 8 A/B in your description is the evidence for that.

Performance

No regression shows in the shadow gate: the geomean is -1.03% and allocs are unchanged. The earlier global-mutex contention concern is resolved by dropping the lock.

Security

No concerns. unsafe is used only for atomic load and store of one concrete type.

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 15s —— View job


Review of PR #928: lock-free Context.Nodes()

I read the diff at head db182c9. I didn't run the tests, so I'm relying on the PR's -race results and the shadow perf gate.

Verdict: the fix is correct and I'd merge it. The remaining items are non-blocking.

Resolved since the last review

  • The unsafe.Pointer rationale (atomic.Pointer embeds noCopy, so vet copylocks would flag by-value copies) is now in the nodesCache field comment.

Correctness

  • Snapshot publication. Each nodeCollector is fully built before StorePointer and never mutated afterwards, so readers see a complete slice.
  • Stale-AST check. root is captured once and stored in the snapshot, so a cache built for an old AST can't be returned for a new one.
  • Concurrent first calls. Duplicate walks are harmless: the last writer wins and the contents are identical.
  • Walk order. The Visitor walk matches ast.Inspect preorder, with no pruning and nil nodes skipped.
  • Nil AST. A nil AST caches an empty snapshot with root == nil, which matches correctly.

Nits

  1. Nodes() doc comment (context.go). The "…one result is kept. Rules must not mutate…" line is still much longer than the lines around it, so re-wrap it. The comment should also state two limits that remain:
    • Replacing c.AST or calling WithAST must not overlap with concurrent Nodes()/Check calls. c.AST is read unsynchronised, and WithAST writes c.AST and c.ParseErr plainly.
    • Copying a Context must not overlap with concurrent use. The struct copy reads nodesCache non-atomically.
  2. Test strength (nodes_concurrent_test.go).
    • The test only asserts err == nil and len(Nodes()) > 0. Add a check that every goroutine sees the same length.
    • Add a rule-specific assertion. For example, SelectStar and ImplicitCrossJoin should each report a violation on this SQL.
    • Compare the result against a plain ast.Inspect walk to pin the "same preorder" claim.
  3. Duplicate walk cost. Under contention, N goroutines on a fresh Context all walk the tree on the first call. That is fine for linting. If it ever matters, a CompareAndSwap doesn't help, since the walk is already done by then. It would take a sync.Once-style design, which has the copyability trade-off you avoided.
  4. PR body. It says LintRW is "awaiting ci(perf): gate LintRW, LintRule L007/L010 and ParseError in the shadow suite #927", but gate-suite.txt on this head already includes it, and the shadow gate shows LintRW rows. Update that line. The LintParallel rows in the gate show "no base" and use -cpu 1, so the -cpu 8 A/B table in the description is the only contention evidence. Keep it there.

Performance

The shadow gate reports no gated metric past its threshold. Allocs/op are unchanged and LintRW B/op is down about 0.13%. Dropping the global mutex resolved the earlier contention concern.

Security

No concerns. unsafe is used only for atomic load and store of one concrete type.

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Claude finished @ajitpratap0's task in 24s —— View job


Review of PR #928: lock-free Context.Nodes()

I read the diff at the current head. I didn't run the tests, so I'm relying on the PR's -race results and the shadow perf gate.

Verdict: the fix is correct and I'd merge it. The remaining items are non-blocking.

Correctness

  • Publication. nodeCollector is fully built before atomic.StorePointer and never mutated afterwards. Readers see a complete slice.
  • Stale-AST check. root is captured once and stored in the snapshot, so a cache built for an old AST is never returned for a new one.
  • Concurrent first calls. Duplicate walks are harmless: the last writer wins and the contents are identical.
  • Walk order. The Visitor walk gives the same preorder as ast.Inspect, with no pruning and nil nodes skipped.
  • unsafe.Pointer. The rationale (atomic.Pointer embeds noCopy, so vet copylocks would flag by-value copies of Context) is now in the field comment.

Nits

  1. Nodes() doc comment (pkg/linter/context.go). One line ("…one result is kept. Rules must not mutate the returned slice or the AST. Replacing") is much longer than the lines around it, so re-wrap it. The comment should also state two limits that remain:
    • Replacing c.AST or calling WithAST must not overlap with concurrent Nodes()/Check calls. c.AST is read unsynchronised, and WithAST writes AST and ParseErr plainly.
    • Copying a Context must not overlap with concurrent use. The struct copy reads nodesCache non-atomically.
  2. Test strength (pkg/linter/nodes_concurrent_test.go).
    • It only asserts err == nil and len(Nodes()) > 0. Add a check that every goroutine sees the same length.
    • Add a rule-specific assertion, for example that SelectStar and ImplicitCrossJoin each report a violation on this SQL.
    • Compare Nodes() against a plain ast.Inspect walk to pin the "same preorder" claim.
  3. PR body. The Summary and Test Plan match the lock-free design. The gate-suite and perf-gate wording is accurate, including that -cpu 1 can't see contention.

Performance

The shadow gate reports no gated metric past its threshold. Allocs/op are unchanged. LintRW B/op is down about 0.13%. A few LintRW and ParseE2E time deltas show a small positive change at p<0.05 (oracle +3.3%, ParseE2E/select_where +2.8%), but the A/A control is noisy at that level and ParseE2E doesn't touch this code. I'd treat them as noise. LintParallel/* has no base yet, and the -cpu 8 A/B in the description is the only contention evidence.

Security

No concerns. unsafe is used only for atomic load and store of one concrete type.

@ajitpratap0
ajitpratap0 merged commit 8c98501 into main Nov 3, 2026
54 of 56 checks passed

This branch was successfully deployed

1 active deployment
Preview — 7a9c5407 Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation linter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant