Skip to content

Commit 1866e27

Browse files
Ajit Pratap Singhclaude
authored andcommitted
fix(core): resolve critical correctness defects in pools, metrics, AST, errors, keywords
Addresses 6 issues from the 2026-04-16 architect review: C1+C2: Pool cleanup leaks in pkg/sql/ast/pool.go - PutSelectStatement now releases GroupBy, Having, Qualify, StartWith, ConnectBy, Joins, Windows, PrewhereClause, Sample, ArrayJoin, Pivot, Unpivot, MatchRecognize, Top, DistinctOnColumns, From, Limit/Offset/Fetch/For, With.CTEs - PutUpdateStatement, PutInsertStatement, PutDeleteStatement similarly audited and fixed - PutExpression: raised MaxWorkQueueSize to 100k and added recursive overflow drain guarded by MaxCleanupDepth - Added PoolLeakCount/ResetPoolLeakCount for observability - New pool_leak_test.go proves stable heap across 1000 complex SELECTs and 2000-element IN-lists C3: Metrics errorsByType memory DoS in pkg/metrics/metrics.go - Replaced err.Error()-keyed map with per-ErrorCode atomic.Int64 buckets - GetStats now allocates 4/op regardless of error cardinality C6: AST Children() coverage in pkg/sql/ast/ - Fixed 14 node types that silently truncated visitor traversal - New children_coverage_test.go uses reflection to assert completeness H6: errors.Error immutability in pkg/errors/errors.go - WithContext/WithHint/WithCause shallow-copy receiver H11: Keyword conflict detection in pkg/sql/keywords/ - New Conflicts()/ResetConflicts() API - Resolved 11 silent conflicts across dialects go test -race ./... passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 70f891e commit 1866e27

25 files changed

Lines changed: 2441 additions & 171 deletions

pkg/errors/errors.go

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ func NewError(code ErrorCode, message string, location models.Location) *Error {
341341
}
342342
}
343343

344-
// WithContext adds SQL context to the error.
344+
// WithContext returns a copy of the error with SQL context attached.
345345
//
346346
// Attaches SQL source code context with highlighting information for
347347
// visual error display. The context shows surrounding lines and highlights
@@ -351,7 +351,9 @@ func NewError(code ErrorCode, message string, location models.Location) *Error {
351351
// - sql: Original SQL source code
352352
// - highlightLen: Number of characters to highlight (starting at error column)
353353
//
354-
// Returns the same Error instance with context added (for method chaining).
354+
// Returns a new Error instance with context added; the receiver is not
355+
// modified. Callers MUST use the return value (e.g. `err = err.WithContext(...)`)
356+
// for the change to take effect.
355357
//
356358
// Example:
357359
//
@@ -363,27 +365,32 @@ func NewError(code ErrorCode, message string, location models.Location) *Error {
363365
// 1 | SELECT * FORM users
364366
// ^^^^
365367
//
366-
// Note: WithContext modifies the error in-place and returns it for chaining.
368+
// Immutability: WithContext is non-mutating — it returns a shallow copy
369+
// with the new field set. This makes *Error safe to share across goroutines
370+
// and call sites without observer effects.
367371
func (e *Error) WithContext(sql string, highlightLen int) *Error {
368-
e.Context = &ErrorContext{
372+
cp := *e
373+
cp.Context = &ErrorContext{
369374
SQL: sql,
370375
StartLine: e.Location.Line,
371376
EndLine: e.Location.Line,
372377
HighlightCol: e.Location.Column,
373378
HighlightLen: highlightLen,
374379
}
375-
return e
380+
return &cp
376381
}
377382

378-
// WithHint adds a suggestion hint to the error.
383+
// WithHint returns a copy of the error with a suggestion hint attached.
379384
//
380385
// Attaches a helpful suggestion for fixing the error. Hints are generated
381386
// automatically by builder functions or can be added manually.
382387
//
383388
// Parameters:
384389
// - hint: Suggestion text (e.g., "Did you mean 'FROM' instead of 'FORM'?")
385390
//
386-
// Returns the same Error instance with hint added (for method chaining).
391+
// Returns a new Error instance with hint added; the receiver is not modified.
392+
// Callers MUST use the return value (e.g. `err = err.WithHint(...)`) for the
393+
// change to take effect.
387394
//
388395
// Example:
389396
//
@@ -395,21 +402,26 @@ func (e *Error) WithContext(sql string, highlightLen int) *Error {
395402
// err := errors.ExpectedTokenError("FROM", "FORM", location, sql)
396403
// // Automatically includes: "Did you mean 'FROM' instead of 'FORM'?"
397404
//
398-
// Note: WithHint modifies the error in-place and returns it for chaining.
405+
// Immutability: WithHint is non-mutating — it returns a shallow copy with
406+
// the Hint field set. This makes *Error safe to share across goroutines
407+
// and call sites without observer effects.
399408
func (e *Error) WithHint(hint string) *Error {
400-
e.Hint = hint
401-
return e
409+
cp := *e
410+
cp.Hint = hint
411+
return &cp
402412
}
403413

404-
// WithCause adds an underlying cause error.
414+
// WithCause returns a copy of the error with an underlying cause attached.
405415
//
406416
// Wraps another error as the cause of this error, enabling error chaining
407417
// and unwrapping with errors.Is and errors.As.
408418
//
409419
// Parameters:
410420
// - cause: The underlying error that caused this error
411421
//
412-
// Returns the same Error instance with cause added (for method chaining).
422+
// Returns a new Error instance with cause added; the receiver is not
423+
// modified. Callers MUST use the return value (e.g. `err = err.WithCause(...)`)
424+
// for the change to take effect.
413425
//
414426
// Example:
415427
//
@@ -425,10 +437,13 @@ func (e *Error) WithHint(hint string) *Error {
425437
// // Handle file not found
426438
// }
427439
//
428-
// Note: WithCause modifies the error in-place and returns it for chaining.
440+
// Immutability: WithCause is non-mutating — it returns a shallow copy with
441+
// the Cause field set. This makes *Error safe to share across goroutines
442+
// and call sites without observer effects.
429443
func (e *Error) WithCause(cause error) *Error {
430-
e.Cause = cause
431-
return e
444+
cp := *e
445+
cp.Cause = cause
446+
return &cp
432447
}
433448

434449
// IsCode checks if an error has a specific error code.

pkg/errors/errors_test.go

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func TestError_WithContext(t *testing.T) {
8484
location := models.Location{Line: 1, Column: 10}
8585

8686
err := NewError(ErrCodeExpectedToken, "expected FROM, got FORM", location)
87-
err.WithContext(sql, 4) // Highlight "FORM" (4 characters)
87+
err = err.WithContext(sql, 4) // Highlight "FORM" (4 characters)
8888

8989
output := err.Error()
9090

@@ -106,7 +106,7 @@ func TestError_WithContext(t *testing.T) {
106106

107107
func TestError_WithHint(t *testing.T) {
108108
err := NewError(ErrCodeUnexpectedToken, "unexpected token", models.Location{Line: 1, Column: 5})
109-
err.WithHint("This is a helpful hint")
109+
err = err.WithHint("This is a helpful hint")
110110

111111
if err.Hint != "This is a helpful hint" {
112112
t.Errorf("WithHint() failed to set hint, got: %s", err.Hint)
@@ -197,7 +197,7 @@ WHERE age > 18`,
197197
for _, tt := range tests {
198198
t.Run(tt.name, func(t *testing.T) {
199199
err := NewError(ErrCodeUnexpectedToken, "test", tt.location)
200-
err.WithContext(tt.sql, 1)
200+
err = err.WithContext(tt.sql, 1)
201201

202202
output := err.formatContext()
203203

@@ -215,10 +215,127 @@ WHERE age > 18`,
215215
func TestError_Unwrap(t *testing.T) {
216216
causeErr := NewError(ErrCodeInvalidSyntax, "cause error", models.Location{})
217217
err := NewError(ErrCodeUnexpectedToken, "wrapper error", models.Location{})
218-
err.WithCause(causeErr)
218+
err = err.WithCause(causeErr)
219219

220220
unwrapped := err.Unwrap()
221221
if unwrapped != causeErr {
222222
t.Errorf("Unwrap() = %v, want %v", unwrapped, causeErr)
223223
}
224224
}
225+
226+
// TestError_WithContext_Immutable verifies WithContext does not mutate the
227+
// receiver: the original *Error is unchanged, and the returned *Error carries
228+
// the new Context. Guards against H6 regressions (observer effects across
229+
// call sites that share a *Error pointer).
230+
func TestError_WithContext_Immutable(t *testing.T) {
231+
orig := NewError(ErrCodeUnexpectedToken, "msg", models.Location{Line: 1, Column: 5})
232+
if orig.Context != nil {
233+
t.Fatalf("precondition: fresh error should have nil Context, got %+v", orig.Context)
234+
}
235+
236+
withCtx := orig.WithContext("SELECT * FORM users", 4)
237+
238+
if orig.Context != nil {
239+
t.Fatalf("WithContext mutated the receiver: orig.Context = %+v", orig.Context)
240+
}
241+
if withCtx.Context == nil {
242+
t.Fatalf("WithContext returned error without Context set")
243+
}
244+
if withCtx.Context.SQL != "SELECT * FORM users" {
245+
t.Errorf("returned Context.SQL = %q, want %q", withCtx.Context.SQL, "SELECT * FORM users")
246+
}
247+
if withCtx.Context.HighlightLen != 4 {
248+
t.Errorf("returned Context.HighlightLen = %d, want 4", withCtx.Context.HighlightLen)
249+
}
250+
if orig == withCtx {
251+
t.Errorf("WithContext returned the same pointer; expected a copy")
252+
}
253+
}
254+
255+
// TestError_WithHint_Immutable verifies WithHint does not mutate the receiver.
256+
func TestError_WithHint_Immutable(t *testing.T) {
257+
orig := NewError(ErrCodeUnexpectedToken, "msg", models.Location{Line: 1, Column: 5})
258+
if orig.Hint != "" {
259+
t.Fatalf("precondition: fresh error should have empty Hint, got %q", orig.Hint)
260+
}
261+
262+
withHint := orig.WithHint("new hint")
263+
264+
if orig.Hint == "new hint" {
265+
t.Fatal("WithHint mutated the receiver")
266+
}
267+
if orig.Hint != "" {
268+
t.Fatalf("receiver Hint unexpectedly changed: %q", orig.Hint)
269+
}
270+
if withHint.Hint != "new hint" {
271+
t.Fatalf("returned Error missing hint, got %q", withHint.Hint)
272+
}
273+
if orig == withHint {
274+
t.Errorf("WithHint returned the same pointer; expected a copy")
275+
}
276+
}
277+
278+
// TestError_WithCause_Immutable verifies WithCause does not mutate the receiver.
279+
func TestError_WithCause_Immutable(t *testing.T) {
280+
cause := NewError(ErrCodeInvalidSyntax, "root", models.Location{})
281+
orig := NewError(ErrCodeUnexpectedToken, "wrapper", models.Location{})
282+
if orig.Cause != nil {
283+
t.Fatalf("precondition: fresh error should have nil Cause, got %v", orig.Cause)
284+
}
285+
286+
withCause := orig.WithCause(cause)
287+
288+
if orig.Cause != nil {
289+
t.Fatalf("WithCause mutated the receiver: orig.Cause = %v", orig.Cause)
290+
}
291+
if withCause.Cause != cause {
292+
t.Fatalf("returned Error missing cause; got %v, want %v", withCause.Cause, cause)
293+
}
294+
if orig == withCause {
295+
t.Errorf("WithCause returned the same pointer; expected a copy")
296+
}
297+
}
298+
299+
// TestError_WithX_SharedReceiver_NoObserverEffects simulates the production
300+
// bug: two call sites holding the same *Error pointer. Before the fix, one
301+
// call site's WithHint would be visible to the other. After the fix, each
302+
// caller gets an independent copy.
303+
func TestError_WithX_SharedReceiver_NoObserverEffects(t *testing.T) {
304+
shared := NewError(ErrCodeUnexpectedToken, "msg", models.Location{Line: 1, Column: 1})
305+
306+
a := shared.WithHint("hint from A")
307+
b := shared.WithHint("hint from B")
308+
309+
if shared.Hint != "" {
310+
t.Fatalf("shared receiver was mutated: shared.Hint = %q", shared.Hint)
311+
}
312+
if a.Hint != "hint from A" {
313+
t.Errorf("a.Hint = %q, want %q", a.Hint, "hint from A")
314+
}
315+
if b.Hint != "hint from B" {
316+
t.Errorf("b.Hint = %q, want %q", b.Hint, "hint from B")
317+
}
318+
if a == b {
319+
t.Errorf("both call sites got the same pointer; expected independent copies")
320+
}
321+
}
322+
323+
// TestError_WithX_Chaining verifies the `err = err.WithA(...).WithB(...)`
324+
// fluent pattern still accumulates all fields on the final returned error.
325+
func TestError_WithX_Chaining(t *testing.T) {
326+
cause := NewError(ErrCodeInvalidSyntax, "root", models.Location{})
327+
err := NewError(ErrCodeUnexpectedToken, "msg", models.Location{Line: 2, Column: 3}).
328+
WithContext("SELECT 1", 1).
329+
WithHint("a hint").
330+
WithCause(cause)
331+
332+
if err.Context == nil || err.Context.SQL != "SELECT 1" {
333+
t.Errorf("chained WithContext not applied: %+v", err.Context)
334+
}
335+
if err.Hint != "a hint" {
336+
t.Errorf("chained WithHint not applied: %q", err.Hint)
337+
}
338+
if err.Cause != cause {
339+
t.Errorf("chained WithCause not applied: %v", err.Cause)
340+
}
341+
}

pkg/errors/example_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,9 @@ func Example_customHints() {
144144
errors.ErrCodeIncompleteStatement,
145145
"incomplete WHERE clause",
146146
location,
147-
)
148-
err.WithContext(sql, 5)
149-
err.WithHint("Add a condition after WHERE, e.g., WHERE age > 18")
147+
).
148+
WithContext(sql, 5).
149+
WithHint("Add a condition after WHERE, e.g., WHERE age > 18")
150150

151151
// Error now includes custom context and hint
152152
_ = err

pkg/errors/examples_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,9 @@ func Example_customHintsEnhanced() {
210210
errors.ErrCodeInvalidSyntax,
211211
"type mismatch in comparison",
212212
location,
213-
)
214-
err.WithContext(sql, 4) // Highlight '18'
215-
err.WithHint("Age comparisons should use numeric values without quotes. Change '18' to 18")
213+
).
214+
WithContext(sql, 4). // Highlight '18'
215+
WithHint("Age comparisons should use numeric values without quotes. Change '18' to 18")
216216

217217
fmt.Println("Custom Hint Example:")
218218
fmt.Println(err.Error())

pkg/errors/formatter_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -553,8 +553,8 @@ WHERE 年龄 > 18`,
553553

554554
for _, tt := range tests {
555555
t.Run(tt.name, func(t *testing.T) {
556-
err := NewError(ErrCodeUnexpectedToken, "test error", tt.location)
557-
err.WithContext(tt.sql, 1)
556+
err := NewError(ErrCodeUnexpectedToken, "test error", tt.location).
557+
WithContext(tt.sql, 1)
558558

559559
got := err.Error()
560560

0 commit comments

Comments
 (0)