feat(DataMapper): eager relation loading via Query<>().With<>() - #574
Open
Yaraslaut wants to merge 6 commits into
Open
feat(DataMapper): eager relation loading via Query<>().With<>()#574Yaraslaut wants to merge 6 commits into
Yaraslaut wants to merge 6 commits into
Conversation
…is unchanged Prepare() always issued SQLPrepareW, even when the handle already held exactly that query. That shape is everywhere: one relation loader, one INSERT or one QuerySingle repeated per record. Skipping the redundant re-prepare took 1000 identical single-row selects on PostgreSQL from 1053 ms to 190 ms (psqlODBC prepares server-side, one round-trip per call); MS SQL Server and SQLite are unaffected, their drivers already fold it away. Everything else Prepare() does still runs unconditionally - the handle carries column bindings, indicators, post-execute callbacks and possibly parameter-array attributes from the previous execution, and those must be torn down either way. A prepared statement can stop being executable without its SQL text changing: SQL Server compiles against object ids, so dropping and recreating a table between two executions of one handle invalidates the plan (42S02), and PostgreSQL rejects a cached plan whose result type changed (0A000). Both are raised while resolving or planning, before the statement has had any effect, so RetryStalePreparedStatement() re-prepares once and runs again for that SQLSTATE family only - a constraint violation is deliberately not retried. Without that retry the reuse broke ~30 MS SQL Server tests while SQLite and PostgreSQL stayed fully green.
CreateTable<Record>() emitted a FOREIGN KEY constraint for every BelongsTo but no index on the column, and none of the supported engines indexes a foreign key implicitly - only MySQL does. The foreign key is exactly the column every HasMany load filters on, so each relation query was a full table scan, making the per-record loading path quadratic in the row count. Measured on SQLite with 1000 owners x 10 children: the on-demand path drops from 6270 ms to 174 ms (36x), and the batched path from 59.8 ms to 9.0 ms (6.6x). Three DDL-string tests grow the matching CREATE INDEX statements. Note this changes the DDL emitted for existing record types: tables created by an earlier version keep their unindexed foreign keys until recreated or indexed by hand.
Touching a relation on a query result loaded it on demand, one query per record - the N+1 problem, with no way to avoid it short of hand-written WhereIn plus a manual stitch (which is what the shipped Chinook example does). Closes #563. auto albums = dm.Query<Album>() .With<&Album::tracks>() // one extra SELECT ... WHERE album_id IN (...) .With<&Album::artist>() // one extra SELECT ... WHERE id IN (...) .All(); After the result set is materialized, each requested relation is resolved for the whole batch: the keys are collected, the related rows fetched with an IN predicate, and the rows distributed to the records in memory. Applies to All(), First(), First(n) and Range(); the calls chain, one per relation. Measured with 1000 owners x 10 children (fastest of 3, foreign key indexed): HasMany 1001 -> 2 queries: 8.7x (SQLite) 45x (PostgreSQL) 45x (MSSQL) BelongsTo 10001 -> 2 queries: 37x (SQLite) 464x (PostgreSQL) 407x (MSSQL) Both servers were on loopback, so those are lower bounds - the gap grows with network round-trip time while the batched figure stays flat. Design notes: - Each With<>() appends a plain function pointer, not a std::function: the relation is named at compile time, so the loader is one stateless instantiation. The builder's type is unchanged, which keeps the fluent chain and the asynchronous execution mode working as they are. - BelongsTo deduplicates foreign keys before querying, so 10 000 children pointing at 1000 parents fetch 1000 rows, not 10 000. - HasMany groups fetched children by binary search over the sorted owner keys rather than scanning the batch per row, and emplaces an empty list for childless owners so their first access does not query for a result already known to be empty. - Keys are sorted and deduplicated rather than hashed: ordering is all a key column type has to provide, while std::hash is not specialized for all of them. - The IN predicate is chunked through the new virtual SqlQueryFormatter::MaxInPredicateValues() (1000 by default) - per-DBMS dispatch rather than a branch on SqlServerType - so a large batch stays within what each dialect's parser accepts and still costs a constant number of queries per relation. - HasOneThrough, HasManyThrough and CompositeForeignKey keep loading on demand; naming one in With<>() is a compile error, not a silent fallback. - Combined with DataMapperOptions { .loadRelations = false }, any relation not named by With<>() throws SqlRequireLoadedError on access - Django 6.1's FETCH_RAISE, for free. The tests assert the statement count through a counting SqlLogger next to the data, so a silent fallback to the on-demand loaders fails the test rather than passing slowly. Covered: both relation kinds, chaining, NULL foreign keys, childless owners, First(n)/Range(), an empty result set, a 1005-owner batch crossing the chunk boundary, and an unrequested relation still loading lazily.
Documents Query<>().With<>() in the usage guide with the measured query counts and speed-ups, and adds a best-practices entry pairing it with the two things that compound with it: indexed foreign keys, and asserting query counts in tests through a SqlLogger rather than assuming them. src/benchmark/ so far measured compile time only. LightweightRelationBenchmark is its runtime counterpart for relation loading: it reports wall-clock time and the number of statements issued per strategy, over the same data, so a change that reintroduces an N+1 shows up as a count and not merely as a slower number. It runs against any ODBC connection string given as its third argument.
Member
|
i like |
…d depth
One level of eager loading is not enough for a chain of relations. Every record
holds its own copy of its BelongsTo target, so reaching a relation of that copy
runs the copy's own lazy loader - the N+1 moves one level down rather than going
away. Naming the whole path fixes that:
auto tracks = dm.Query<Track>()
.With<&Track::album>() // 1 query for all albums
.With<&Track::album, &Album::artist>() // 1 query for their artists
.All();
Each level is resolved for every record the level above it reached, so a path of
any length costs a constant number of queries per level - three statements in
total here, for any number of tracks. A path may equally run through the "many"
side (With<&Album::tracks, &Track::genre>()): the middle level fans out and the
one below it is still a single query, not one per child.
For a whole object graph rather than named paths, the query takes a depth:
auto tracks = dm.Query<Track, DataMapperOptions { .eagerLoadDepth = 2 }>().All();
which batch-loads every BelongsTo and HasMany reachable within that many levels.
The depth is a compile-time constant, and that is what makes a cyclic relation
graph - a self-referencing record, or A -> B -> A - terminate instead of
instantiating forever. HasOneThrough, HasManyThrough and CompositeForeignKey are
skipped and keep their on-demand behaviour.
Implementation notes:
- The preloaders now address the batch by pointer. Past the first level the
targets are not contiguous: a BelongsTo target lives in the owner's own
unique_ptr and a HasMany list holds shared_ptrs, so only their addresses can
be gathered. The span<Record> entry point adapts a freshly materialized result
set onto that.
- BelongsTo::LoadedRecord() and HasMany::LoadedRecords() report what is loaded
without running the on-demand loader. Walking the path through the ordinary
accessors would have re-created the N+1 inside the walk itself.
- The batched loaders skip a relation that is already in memory, which makes
preloading idempotent: overlapping paths, or a named path next to a depth
walk, fetch each relation once.
Six new tests, all asserting statement counts: a three-level BelongsTo chain, a
path through a HasMany, both depth settings, and the no-double-fetch case.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
The CI style gate runs clang-format over every tracked source; these three files were edited outside the formatter.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #563.
Relations could only be loaded one record at a time. Touching
album.tracksover a result set of1000 albums issued 1001 queries, and the only mitigation was a hand-written
WhereInplus a manualstitch — which is exactly what the shipped Chinook example does.
Relations nest, and one level of eager loading is not enough for a chain: each record holds its own
copy of its
BelongsTotarget, so reaching a relation of that copy runs the copy's own lazy loader— the N+1 moves one level down. Naming the whole path resolves each level for everything the level
above reached:
And when a whole object graph is wanted rather than named paths, the query takes a depth:
Two adjacent bottlenecks found while measuring are fixed in the same branch, because the eager path
is 6.6x slower without the first and the remaining per-record path 5.6x slower without the second.
Performance impact
Measured with
LightweightRelationBenchmark(added here), 1000 owners x 10 children,-O2 -DNDEBUG,fastest of 3 runs after a warm-up, statement counts captured through a
SqlLoggerrather thanestimated. Both servers on loopback, so the speed-ups are lower bounds — they grow with network RTT
while the batched figure stays flat.
HasManyon demandHasManywithWith<>()BelongsToon demandBelongsTowithWith<>()Unindexed foreign keys (fixed here): no supported engine indexes a foreign key implicitly, so
every relation query was a full table scan. SQLite, 1000x10 — on-demand path 6270 ms → 174 ms (36x),
batched path 59.8 ms → 9.0 ms (6.6x). At 2000 owners the unindexed on-demand path took 46 seconds.
Redundant re-prepare (fixed here): 1000 identical single-row selects, prepare-each-time vs
prepare-once — PostgreSQL 1053 ms → 190 ms (5.6x), SQLite 21.3 ms → 18.9 ms, MSSQL unchanged. This
also speeds up per-record
Createloops, which re-prepared the same INSERT per row.Not a bottleneck, deliberately left alone: installing the lazy loaders costs nothing measurable
(
loadRelationstrue vs false on a 1000-row query: 0.35 vs 0.32 ms on SQLite, 1.31 vs 1.33 ms onPostgreSQL) — the closures fit libc++'s
std::functioninline buffer.Risk assessment
the SQL text changing. Without a guard this broke ~30 MS SQL Server tests with
42S02 Invalid object name— SQL Server compiles against object ids, so a dropped-and-recreated table invalidatesthe plan — while SQLite and PostgreSQL stayed fully green. Handled by re-preparing once for the
stale-plan SQLSTATE family (
42S02,42P01,0A000,26000,42P05). All of those are raisedwhile resolving or planning, before the statement has had any effect, so re-executing is safe; a
constraint violation is not retried. Residual risk: a multi-statement batch prepared through
Prepare()that fails partway with one of those states would re-run its earlier statements — nosuch call site exists today (migrations go through
ExecuteDirect, which clears the reuse flag).CreateTable<Record>()now emitsCREATE INDEX "<table>_<column>_index"perBelongsTo. Tables created by an earlier version keep unindexed foreign keys until recreated.Three DDL-string tests were updated.
SqlStatementgains a privateboolmember;SqlQueryFormattergains a virtual method(vtable layout change). Source-compatible, not binary-compatible — consistent with the project's
header-heavy design.
fully materialized and its cursor closed, so no second cursor is open on the connection — MARS is
not required.
SqlQueryFormatter::MaxInPredicateValues()(1000),a virtual hook per AGENT.md rather than a branch on
SqlServerType. The chunk-boundary case iscovered by a 1005-owner test.
eagerLoadDepthinstantiates the loader for the whole reachable relation graph, so a deepvalue on a richly connected record costs compile time — the same pressure
src/benchmark/existsto measure. It is opt-in and defaults to
0. The depth being a compile-time constant is also whatterminates a cyclic graph (a self-referencing record, or A → B → A).
paths, or a named path next to a depth walk, fetch each relation once. Two
BelongsToaccessorswere added (
LoadedRecord(),LoadedRecords()) that report what is loaded without running theon-demand loader — walking a path through the ordinary accessors would have re-created the N+1
inside the walk.
Test coverage
17 new test cases.
src/tests/DataMapper/EagerLoadingTests.cppasserts the statement countthrough a counting
SqlLoggeralongside the data, so a silent fallback to the on-demand loadersfails the test instead of passing slowly: both relation kinds, chaining, NULL foreign keys, childless
owners,
First(n)/Range(), an empty result set, a batch crossing the IN-chunk boundary, and anunrequested relation still loading lazily, a three-level
BelongsTochain, a path through aHasMany, botheagerLoadDepthsettings, and a named path not being re-fetched by the depth walk.src/tests/SqlStatementDbTests.cppadds two cases for the reuse path, including a schema changeunderneath a reused prepared statement.
Databases tested
Full suite,
clang-debug(ASan + UBSan + PEDANTIC/-Werror), against isolated databases:sqlite3— 1417 passed, 1 skippedpostgres(Docker 16.4) — 1416 passed, 2 skippedmssql2022(Docker) — 1415 passed, 3 skippedCompilers tested
clang-debug(Apple clang 17) — full suite, all three databases-fsyntax-only). A fullGCC build is not possible on this macOS host for a pre-existing reason (
std::stacktraceisunavailable in Homebrew GCC,
SqlLogger.cpp:291), so thegcc-releaseleg AGENT.md asks for isleft to CI. No MSVC/clang-cl run either — no Windows host available.
LIGHTWEIGHT_BUILD_MODULES=ONnot built: this change adds no namespace-scope entity to a publicheader (
MaxInPredicateValuesis a member function,ForEachChunka function template), so theinternal-linkage rule that configuration enforces does not apply.
Both Docker servers were given dedicated databases for these runs (
test563,LightweightTest563):another suite was running concurrently against the shared ones and both drop and recreate the same
tables, which corrupted an earlier run.
🤖 Generated with Claude Code