Repository navigation
Proposal: duplication-free eager loading — a UNION ALL "sectioned query" strategy for Preload (production case: 180s → 24s) #738
Replies: 2 comments 3 replies
|
I've very conflicted about this.... While of course Bob needs to include some "magic" to improve the developer experience, I wonder if this is a bit too much magic which then becomes hard to reason about and maintain. |
|
Thanks for the honest read — that's a fair concern, and it matches our own hesitation about pitching this as core behavior. Two clarifications that hopefully narrow the risk surface, and then a suggestion. On downstream effects: the proposal doesn't touch counts or joins at all. It's an alternative execution strategy for On "too much magic": agreed. Looking back, the magic lives in the "automatic fallback" part of the original proposal — and that's a kind of behavior bob has never had. In bob, the loading strategy is always something the user picks explicitly at the call site (
In other words, this keeps bob's property that reading your own code tells you exactly what SQL will run. The implementation follows the same principle: the query is assembled from bob's existing builder and the generated models' metadata; the genuinely new code should be limited to composing the sections and the layer that dispatches result rows by That said, given your hesitation, maybe the right first step is the option from Question 1: we build this outside the core, as a separate package on top of bob's public APIs, validate it against our own test suite and production workload, and report back. The numbers we'd report: before/after per-endpoint and per-query latency from our production APM, CPU/heap data from the continuous profiler, observed fallback rates, and controlled per-shape benchmarks (single edge / siblings / nested). No maintenance burden lands on bob, and the idea gets validated — or falsified — with evidence instead of speculation. The experiment would also answer the complexity question with something concrete — how much code this actually takes, and whether it truly stays within bob's public API — instead of us debating it in the abstract. If you'd rather keep it discoverable under bob's roof instead, an explicitly experimental in-tree package (clearly marked, no stability promises) would also work for us — but we're equally happy starting fully outside and only coming back with results. Two questions if we go that route:
|
Uh oh!
There was an error while loading. Please reload this page.
Hi! We run bob in production on a large PostgreSQL workload and have contributed a few loader-performance PRs before (
= ANYloader queries, O(N+M) map stitching, composite-keyIN). This discussion is about a bigger topic: the execution strategy ofPreloaditself. We have a working PoC with measurements and would love your thoughts on the direction before we invest in a real implementation.One note on how this relates to our open perf PRs (#718, #726, #727, #729, #736): those are small, self-contained improvements for pain we're feeling in production today, so we'd be grateful if they could be considered independently of this discussion. What's proposed here is still at the idea stage and will naturally take longer to mature — and even if it lands, the current JOIN strategy remains the default and the automatic fallback, so those improvements stay valuable either way.
The problem
Preloadloads to-one relations viaLEFT JOIN. When the parent set is large and many parents share the same related row, the related row's bytes are transferred once per parent row. Real numbers from one of our production queries:tasksrows sharing just 49serversrows (duplication factor ≈ 1,261×)Rewriting that call site from
PreloadtoThenLoadbrought it down to 24s. Profiling splits the JOIN penalty in two:ThenLoadin our benchmarks.Why we don't want to just use ThenLoad everywhere
ThenLoadfixes both issues but costs one extra query (RTT) per relation, and the parent filter's result set is only shared via a second round trip. On remote databases with non-trivial latency, N preloaded relations mean N extra RTTs. What we actually want is:Preload's strength)ThenLoad's strength)Hypothesis: sectioned UNION ALL queries
Stack the parent section and the relation section vertically instead of joining horizontally:
The scanner dispatches on
_setinto per-section typed scanners, then stitches children to parents by key with an O(N+M) map — same shared-instance semantics asThenLoad. NULL-padded columns are close to free on the Postgres wire format (a 4-byte length header per NULL column).PoC results (real production data, PostgreSQL 17):
ThenLoadThe part we care most about: not just the first relation
A single-edge version already helps, but most of our heavy call sites preload several sibling relations and/or nested chains (
Preload.Cve.MasterCve(Preload.MasterCve.PublicCve())). We audited our codebase: of our ~150 heaviest preload call sites, roughly 60% are nested chains and another ~15% are "2nd or later sibling" positions. A first-edge-only implementation would leave most of the win on the table.Our hypothesis is that siblings and nesting are not separate features — the
Preloadtree flattens into a sequence of sections:MATERIALIZEDCTE (so the next level can semi-join it, and it executes exactly once),UNION ALLsection,src; nesting = sections referencing the previous level's CTE.Every row travels the wire once, at any depth/breadth, in one query.
On the codegen side we believe this avoids combinatorial explosion: generate only per-edge section parts (SQL fragment + typed scanner), and compose any tree at runtime by assigning
_setnumbers. Two refinements that fall out naturally:MainUser/SubUser→users) can merge into one section withid IN (main ∪ sub), removing even cross-edge duplication;Shapes that don't fit (e.g. ordering the parent by a related column, which genuinely requires the JOIN) would fall back to the current JOIN strategy automatically, so this could ship as an opt-in strategy with zero behavior change by default.
Alternatives we considered and rejected
json_aggof related rows): 1 query and duplication-free, but abandons the binary wire format — the JSON encode/decode cost erases the alloc savings, and type fidelity suffers (numeric/timestamp/bytea).WHERE(double evaluation of a potentially expensive filter) or go through a temp table; snapshot consistency also needs an explicit transaction.(SELECT s FROM servers s WHERE ...) AS server): still duplicated per parent row — doesn't address the problem.Questions
Preloadexecution strategy producing sectionedUNION ALLqueries — sound acceptable for bob, or would you prefer this living outside the core as an extension?Preloadtree intoMATERIALIZEDCTEs + sections" approach (planner behavior around CTE materialization, NULL-padding wire cost on wide trees)?We're happy to contribute the implementation, benchmarks (we have real-workload data at multiple duplication factors), and docs. Dialect-wise the PoC is PostgreSQL; the SQL shape itself is portable, but we'd propose starting psql-only behind the opt-in.
All reactions