Release aggregate instances when sqlite finishes with them - #722
Conversation
The check at the top of the destroy function returns when the aggregate context still holds an instance, which is exactly when there is something to clean up. So instances were never unlinked, and every aggregate call left one behind for as long as the connection stayed open.
|
@flavorjones - here's a script to reproduce the leak. It creates a custom aggregator and then queries against it continually: ruby sqlite3-aggregate-leak.rb # released gem
SQLITE3_PATH=/path/to/sqlite3-ruby ruby sqlite3-aggregate-leak.rb # this branchBefore — sqlite3 2.9.5: After — this branch:
|
jeremy
left a comment
There was a problem hiding this comment.
Approve. The diagnosis and the one-character fix are both right, and I reproduced the leak and its removal independently.
rb_sqlite3_aggregate_instance_destroy (ext/sqlite3/aggregator.c:100) reads
if (!inst_ptr || (inst = *inst_ptr)) {
return;
}inst is a VALUE, so (inst = *inst_ptr) is truthy exactly when an instance exists — the guard returns in the one case where there is something to release, and the body has been unreachable since the aggregate rewrite. Adding the ! restores the intent.
Verified locally
Ruby 4.0.6 (arm64-darwin23) and Ruby 3.4.10 / 4.0.5 (x86_64-linux), building each branch from source and loading that build rather than the installed precompiled gem.
Define an aggregator, run 50 aggregations, GC.start(full_mark: true, immediate_sweep: true), count live handler instances:
| build | live handlers |
|---|---|
main (07c92bc) |
51 |
| #722 | 1 |
| #723 alone | 51 |
| #722 + #723 | 1 |
bundle exec rake test is clean over 10 consecutive runs on this branch, and the new test_aggregate_instances_are_released_after_each_query fails on main (Expected: 1, Actual: 6), so it does bite.
No CI has run on this branch
gh pr checks 722 reports "no checks reported on the 'fix/release-aggregate-instances' branch" and the PR sits at mergeStateStatus: BLOCKED, while #723 — filed the same day by the same author — has 135 green checks. @flavorjones, could you kick the workflows off here? This is the smaller and lower-risk of the two patches and it's the one with no signal.
Worth merging this before, or together with, #723
Not a correctness dependency — #723 stands on its own and is a clear net win either way. But #723's pin_aggregators walks each aggregator wrapper's -instances array on every GC mark and calls rb_gc_mark, the pinning mark, on every element. That is correct in itself: sqlite stores each live instance's VALUE inside sqlite3_aggregate_context() memory, which the collector never scans or relocates. But until this PR lands that array never drains, so the pinned set grows without bound.
The pinning is unambiguous. 200 queries × 200 groups on one connection, then sample 300 retained wrappers and compact:
| build | retained wrappers | relocatable? |
|---|---|---|
main |
4000 | yes — 300/300 sampled moved |
| #723 alone | 4000 | no — 0/300 moved, and 40,004 objects excluded from relocation entirely |
| #722 (+ #723) | 0 | n/a |
Whether that costs anything depends on the workload, and I'd rather give you both results than just the flattering one. With a handler that retains an array per group — the Median in your benchmark above — retention dominates and pinning is invisible: 216 pages/68.1% occupancy on main vs 217/67.1% on #723. With a light handler whose wrappers end up scattered among collectable garbage, it shows clearly, at an essentially identical live set (~118,410 slots):
pages after GC.compact |
pages actually needed | |
|---|---|---|
main |
125 → 94 | 91 |
| #723 alone | 121 → 111 | 88 |
So compaction recovers 31–40 pages on main and 10 on #723-alone, leaving ~18% more heap for the same live data. Merging this PR bounds the pinned set and the question goes away.
(For the record, and correcting something I expected to find and didn't: GC mark time is not amplified — ~18.6 ms/full-GC on main vs ~16.1 ms on #723 at 400 queries. The retention leak already pays that today.)
Nits
-
This branch and #723 conflict textually in
test/test_integration_aggregate.rb— both add a helper class and tests at the same two points. Trivial to resolve, keep both, but whoever merges second will hit it. -
No
CHANGELOG.mdentry. There's a## next / unreleasedsection and comparable fixes (#710, #711) got one. Suggested, under### Fixed:
The check at the top of the destroy function returns when the aggregate context still holds an instance, which is exactly when there is something to clean up. So instances were never unlinked, and every aggregate call left one behind for as long as the connection stayed open.