⚡ Bolt: Eliminate closure allocations in row scanning#80
Conversation
Refactors the direct row scanning hot path to use a data-driven approach instead of closure-based binding. By pre-calculating scanning hints (scanDirectKind) and buffer-clearing indices (clearIndices) in the rowScanPlan, we eliminate closure allocations and reduce per-row overhead. Measurable Impact: - Reduces allocations for SQLite point lookups by ~14% (58 -> 50 allocs/op). - Reduces allocations for prepared point lookups by ~17% (46 -> 38 allocs/op). This optimization improves efficiency for all queries using direct scanning into structs or slices, particularly benefiting workloads with small result sets or high-frequency lookups. Co-authored-by: cungminh2710 <8063319+cungminh2710@users.noreply.github.com>
|
👋 Jules, reporting for duty! I'm here to lend a hand with this pull request. When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down. I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job! For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with New to Jules? Learn more at jules.google/docs. For security, I will only act on instructions from the user who triggered this task. |
Greptile SummaryThis PR bundles two independent features into one commit: (1) the closure-elimination scanning refactor described in the title, and (2) new query features —
Confidence Score: 5/5Safe to merge; the scanning refactor is behaviour-preserving and fixes a pre-existing pointer-field bug, and both new query features are well-tested. The scanning refactor correctly replicates the old closure semantics through static dispatch, newScanTargets initialises all scan targets before overriding direct columns, and clearIndices logic is equivalent to what bind computed at runtime. The only gap found is that the Distinct + DistinctOn mutual-exclusion check lives in compile() but not in writeSQL(), so it is silently bypassed when the subquery is embedded in INSERT … SELECT; this is a narrow edge case that produces subtly wrong SQL rather than a crash or data loss. pkg/rain/query_select.go — the DISTINCT+DISTINCT ON guard in compile() is not mirrored in writeSQL().
|
| Filename | Overview |
|---|---|
| pkg/rain/model.go | Core refactor: removes closure-based boundRowScanPlan/bind in favour of type-grouped static dispatch (int64ValueCols, stringValueCols, …); fixes *time.Time pointer handling in newScanTargets; moves clearIndices pre-calculation into the cached plan. |
| pkg/rain/query_select.go | Adds DistinctOn() support and withSQLiteInsertSelectConflictWhere rewriting; the DISTINCT+DISTINCT_ON mutual-exclusion guard in compile() is not mirrored in writeSQL(), creating a silent mis-generation when the subquery is used via INSERT…SELECT. |
| pkg/rain/query_insert.go | Adds INSERT … SELECT via Select()/Columns() methods; validateSources refactored out of insertAssignments; MySQL restriction for ON DUPLICATE KEY UPDATE with SELECT is correctly guarded. |
| pkg/rain/query_insert_test.go | Comprehensive test coverage for INSERT…SELECT: Postgres, SQLite with conflict+returning, MySQL, invalid column ownership, generated columns, and multiple-source error. |
| pkg/rain/query_select_test.go | New TestSelectDistinctOnToSQL covers single/multi-column DISTINCT ON, unsupported dialects, compound-query rejection, and aggregate-helper rejection. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[ToSQL / Scan] --> B{selectQuery set?}
B -- yes --> C[toSelectSQL]
B -- no --> D[insertAssignments]
C --> E[validateSources]
C --> F[validateInsertSelectColumns]
C --> G{SQLite + conflict?}
G -- yes --> H[withSQLiteInsertSelectConflictWhere\nadd WHERE 1=1 if needed]
G -- no --> I[selectQuery unchanged]
H --> J[selectQuery.writeSQL ctx]
I --> J
J --> K[writeConflictClause]
K --> L[writeReturning]
subgraph Row Scanning
M[newScanTargets] --> N["init all: scanTargets[i] = &scanned[i]"]
N --> O["override direct cols with sql.Null*"]
O --> P[rows.Scan scanTargets...]
P --> Q[scanDirectRow]
Q --> R[int64ValueCols / int64PointerCols]
Q --> S[stringValueCols / stringPointerCols]
Q --> T[bool / float64 / time cols]
Q --> U[otherCols → assignRawValueToField]
end
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
pkg/rain/query_select.go:296-302
**`DISTINCT + DISTINCT ON` mutual-exclusion guard skipped for INSERT subqueries**
`toSelectSQL` calls `selectQuery.writeSQL(ctx)` directly, bypassing `compile()`. The validation `if q.distinct && len(q.distinctOn) > 0 { return error }` lives only in `compile()`. If a caller sets both `.Distinct()` and `.DistinctOn(...)` on the subquery and passes it to `Insert().Select(subquery)`, `writeSQL` silently emits only `SELECT DISTINCT ...` (the `else if` branch drops `DISTINCT ON`), producing wrong SQL with no diagnostic. All other sub-query validation guards (`q.table == nil`, dialect check for `FeatureSelectDistinctOn`) are already present in `writeSQL`, so only this one guard needs to be moved.
Reviews (3): Last reviewed commit: "refactor(rain): enable optimized scannin..." | Re-trigger Greptile
| } | ||
| } | ||
| } | ||
| return nil |
There was a problem hiding this comment.
Redundant re-index after already capturing
val
At the top of the loop, val := scanned[idx] captures the value, but the isDirect fallback (reached when directKind == scanDirectNone or when every type assertion returns ok = false) reads scanned[idx] again instead of using the already-captured local. The two expressions always produce the same result because scanned is not mutated inside the loop, but using val avoids the redundant slice read and makes it obvious the fallback is operating on the same value that was inspected by the switch.
Prompt To Fix With AI
This is a comment left during a code review.
Path: pkg/rain/model.go
Line: 479
Comment:
**Redundant re-index after already capturing `val`**
At the top of the loop, `val := scanned[idx]` captures the value, but the `isDirect` fallback (reached when `directKind == scanDirectNone` or when every type assertion returns `ok = false`) reads `scanned[idx]` again instead of using the already-captured local. The two expressions always produce the same result because `scanned` is not mutated inside the loop, but using `val` avoids the redundant slice read and makes it obvious the fallback is operating on the same value that was inspected by the switch.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
I've rebased and examined the latest changes in main. It appears that PR #78 (commit 95fb913) was recently merged, which implements this exact optimization using static type-grouped dispatch (grouped slices in rowScanPlan). This achieves the same 14-17% allocation reduction I measured. Since the goal is already accomplished in main with a similar architecture, I'll close this PR as obsolete. Thank you!
Extends the static type-grouped scanning optimization to include pointers to simple types (e.g., *string, *int64). Previously, these fields were excluded and fell back to generic reflection. By allowing these types into the direct path, we can scan them into specialized sql.Null* types, reducing reflection overhead and improving efficiency for models with nullable pointer fields. Co-authored-by: cungminh2710 <8063319+cungminh2710@users.noreply.github.com>
Extends the static type-grouped scanning optimization to include pointers to simple types (e.g., *string, *int64). Includes a fix for a panic in Postgres integration tests where *time.Time fields were incorrectly bypassing the specialized NullTime scanner target. Co-authored-by: cungminh2710 <8063319+cungminh2710@users.noreply.github.com>
This PR implements a performance optimization in the ORM's row scanning logic by replacing closure-based binding with a data-driven path.
💡 What:
boundRowScanPlan,boundColumnScan, and thebindmethod frompkg/rain/model.go.scanDirectKindto the plan to identify basic Go types that can be scanned using specializedsql.Null*types without further allocations.clearIndicesin therowScanPlanto identify which indices in the reusedscannedbuffer need to be nullified between rows.scanDirectRowWithPlanandscanRowsAgainstTableDirectto use these pre-calculated hints.🎯 Why:
The previous implementation created a closure for every column of every query execution (even when using cached plans). While the plan was cached, the "bound" state involving these closures was re-allocated per execution. This refactor moves that logic into a static, data-driven switch in the scanning loop, eliminating those allocations.
📊 Impact:
🔬 Measurement:
Verified using:
go test -run '^$' -bench 'BenchmarkSQLite.*SelectPointLookup/small' -benchmem ./pkg/rainAll existing tests passed.
PR created automatically by Jules for task 10108816982189280228 started by @cungminh2710